<?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: Libme</title>
    <description>The latest articles on DEV Community by Libme (@libme).</description>
    <link>https://dev.to/libme</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%2F4062668%2F1762b3d3-a3e8-4856-a46b-270e29821fed.png</url>
      <title>DEV Community: Libme</title>
      <link>https://dev.to/libme</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/libme"/>
    <language>en</language>
    <item>
      <title>asyncio.gather Raised an Exception but the Other Tasks Kept Running: TaskGroup vs return_exceptions</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Sat, 14 Nov 2026 22:58:58 +0000</pubDate>
      <link>https://dev.to/libme/asynciogather-raised-an-exception-but-the-other-tasks-kept-running-taskgroup-vs-returnexceptions-4kg1</link>
      <guid>https://dev.to/libme/asynciogather-raised-an-exception-but-the-other-tasks-kept-running-taskgroup-vs-returnexceptions-4kg1</guid>
      <description>&lt;p&gt;If one coroutine in &lt;code&gt;asyncio.gather()&lt;/code&gt; raises, the exception propagates to your &lt;code&gt;await&lt;/code&gt;, but the sibling tasks are &lt;strong&gt;not cancelled&lt;/strong&gt;. They keep running in the background, still holding connections and still writing. Since Python 3.11, &lt;code&gt;asyncio.TaskGroup&lt;/code&gt; fixes this: the first failure cancels the siblings, and you handle the result as an &lt;code&gt;ExceptionGroup&lt;/code&gt;. Use &lt;code&gt;gather(..., return_exceptions=True)&lt;/code&gt; only when you genuinely want every result, failures included.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually happens when gather fails?
&lt;/h2&gt;

&lt;p&gt;The symptom usually shows up as a "ghost" side effect: a request handler returns a 500, yet a database row or an outbound API call from a sibling coroutine still lands a second later. Here is a minimal reproduction:&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;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&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;worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delay&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;fail&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;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"{name} failed"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"{name} finished"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CancelledError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"{name} cancelled"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;raise&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;main&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="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gather&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nf"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nf"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"b"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"caught:"&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&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="c1"&gt;# keep the loop alive to observe "b"&lt;/span&gt;

&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;caught: a failed
b finished
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Task &lt;code&gt;b&lt;/code&gt; was never cancelled. The caller already moved on to its error path while &lt;code&gt;b&lt;/code&gt; completed its work. The asyncio documentation describes this behavior for the default &lt;code&gt;return_exceptions=False&lt;/code&gt;: the first exception is propagated to the awaiter, and the other awaitables in the sequence are not cancelled.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: &lt;code&gt;gather&lt;/code&gt; reports the first failure but does not clean up after it.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What do people try first (and why it falls short)?
&lt;/h2&gt;

&lt;p&gt;Three dead ends come up repeatedly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wrapping the &lt;code&gt;gather&lt;/code&gt; in &lt;code&gt;try/except&lt;/code&gt; and cancelling the gathered future.&lt;/strong&gt; Cancelling the future returned by &lt;code&gt;gather&lt;/code&gt; does cancel the children, but you only get that handle if you stored it, and by then the failure has already been raised and the siblings have had time to do damage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adding &lt;code&gt;return_exceptions=True&lt;/code&gt; and forgetting to check the results.&lt;/strong&gt; Exceptions are now returned as ordinary list items, so nothing raises at all. A failed task becomes a silently ignored object in a list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bumping timeouts.&lt;/strong&gt; A timeout around &lt;code&gt;gather&lt;/code&gt; cancels the whole thing, but it does not help when the failure is fast and the siblings are slow.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: each workaround either needs manual bookkeeping or turns a loud failure into a quiet one.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How does TaskGroup change this?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;asyncio.TaskGroup&lt;/code&gt; (Python 3.11 and later) gives structured concurrency: the &lt;code&gt;async with&lt;/code&gt; block does not exit until every task in it is done, and if any task fails, the rest are cancelled.&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;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&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;worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delay&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;fail&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;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"{name} failed"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"{name} finished"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CancelledError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"{name} cancelled"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;raise&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;main&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="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;TaskGroup&lt;/span&gt;&lt;span class="p"&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;tg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;tg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"a"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;tg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;worker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"b"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;eg&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;exc&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;eg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;exceptions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"caught:"&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;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;b cancelled
caught: a failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things trip people up here. First, errors always arrive wrapped in an &lt;code&gt;ExceptionGroup&lt;/code&gt;, even if only one task failed, so a plain &lt;code&gt;except RuntimeError:&lt;/code&gt; will not catch it; you need &lt;code&gt;except*&lt;/code&gt; (also 3.11+). Second, the &lt;code&gt;try/except*&lt;/code&gt; has to wrap the &lt;code&gt;async with&lt;/code&gt;, not sit inside it, because the group raises when the block exits.&lt;/p&gt;

&lt;p&gt;The cost is real: you cannot collect partial results from the survivors, because they were cancelled. If you want "run all, tell me which failed", TaskGroup is the wrong shape.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: TaskGroup trades partial results for guaranteed cleanup, and wraps every error in an ExceptionGroup.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When is return_exceptions=True the right choice?
&lt;/h2&gt;

&lt;p&gt;When the tasks are independent and a partial result is useful, such as fetching 50 URLs where 3 failing should not discard the other 47:&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;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;int&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;i&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"item {i} is bad"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.01&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;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;10&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;main&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="kc"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gather&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;return_exceptions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nb"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;BaseException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nb"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;BaseException&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"ok:"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"failed:"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&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="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Always split the results explicitly, as above. One subtlety: with &lt;code&gt;return_exceptions=True&lt;/code&gt;, a &lt;code&gt;CancelledError&lt;/code&gt; from a child can also appear in the results list as a value, so check against &lt;code&gt;BaseException&lt;/code&gt;, not just &lt;code&gt;Exception&lt;/code&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Need&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;th&gt;Siblings on failure&lt;/th&gt;
&lt;th&gt;Error shape&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;All-or-nothing, clean shutdown&lt;/td&gt;
&lt;td&gt;&lt;code&gt;TaskGroup&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cancelled&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ExceptionGroup&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best effort, want every outcome&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gather(return_exceptions=True)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Keep running to completion&lt;/td&gt;
&lt;td&gt;Values in a list&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Legacy code on Python 3.10 or older&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gather&lt;/code&gt; plus manual cancel&lt;/td&gt;
&lt;td&gt;Keep running&lt;/td&gt;
&lt;td&gt;First exception only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Background work that must outlive the caller&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;create_task&lt;/code&gt; with a stored reference&lt;/td&gt;
&lt;td&gt;Independent&lt;/td&gt;
&lt;td&gt;Handled in the task&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: choose by whether a partial result is worth more than a guaranteed stop.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do fire-and-forget tasks disappear mid-run?
&lt;/h2&gt;

&lt;p&gt;A related trap: &lt;code&gt;asyncio.create_task(coro())&lt;/code&gt; without keeping the returned task. The event loop holds only a weak reference to tasks, so an unreferenced task can be garbage-collected before it finishes, sometimes producing &lt;code&gt;Task was destroyed but it is pending!&lt;/code&gt; in the logs. Keep a strong reference and drop it when the task is done:&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;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;

&lt;span class="n"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;set&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;spawn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;coro&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;coro&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;background&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_done_callback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;background&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;discard&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;task&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If an exception in such a task is never retrieved, you will see &lt;code&gt;Task exception was never retrieved&lt;/code&gt; at garbage collection time. Add a done callback that logs &lt;code&gt;task.exception()&lt;/code&gt; if you want failures visible immediately.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: an unreferenced task is a task you have agreed to lose.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What if you are stuck below Python 3.11?
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;taskgroup&lt;/code&gt; and &lt;code&gt;exceptiongroup&lt;/code&gt; backport packages exist on PyPI, but check their maintenance status before depending on them. Alternatively, if you already use AnyIO or Trio, both provide task groups with the same cancel-siblings semantics. If you want that behavior across Python versions without writing your own plumbing, AnyIO is the library that gives you task groups on top of asyncio with a consistent API. The drawback is an extra dependency and a slightly different cancellation model than native asyncio, which can surprise you when mixing the two styles in one codebase.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does asyncio.gather cancel other tasks when one raises?&lt;/strong&gt;&lt;br&gt;
No. With the default &lt;code&gt;return_exceptions=False&lt;/code&gt;, the first exception propagates to the awaiter, and the remaining tasks continue running. Use &lt;code&gt;asyncio.TaskGroup&lt;/code&gt; if you want them cancelled.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I catch an exception from asyncio.TaskGroup?&lt;/strong&gt;&lt;br&gt;
Use &lt;code&gt;except*&lt;/code&gt; around the &lt;code&gt;async with&lt;/code&gt; block, for example &lt;code&gt;except* ValueError as eg:&lt;/code&gt;. Errors are always delivered inside an &lt;code&gt;ExceptionGroup&lt;/code&gt;, so a plain &lt;code&gt;except ValueError:&lt;/code&gt; will not match.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I use gather or TaskGroup in new code?&lt;/strong&gt;&lt;br&gt;
Prefer &lt;code&gt;TaskGroup&lt;/code&gt; on Python 3.11 and later when tasks belong together and one failure should stop the rest. Keep &lt;code&gt;gather(return_exceptions=True)&lt;/code&gt; for independent, best-effort fan-out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;For new code on Python 3.11+, default to &lt;code&gt;TaskGroup&lt;/code&gt; so a failure cannot leave orphan work behind. Reach for &lt;code&gt;gather(return_exceptions=True)&lt;/code&gt; when you want every outcome and will inspect each one. Hold strong references to any task you spawn and do not await. On older Python, either upgrade or use AnyIO or Trio to get the same structured behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/uptime-monitor-says-100-while-users-see-errors-what-to-check-and-when-paid-monitoring-is-worth-it-489p"&gt;Uptime Monitor Says 100% While Users See Errors: What to Check, and When Paid Monitoring Is Worth It&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/add-full-text-search-to-your-app-before-reaching-for-elasticsearch-lmc"&gt;Add Full-Text Search to Your App Before Reaching for Elasticsearch&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/github-actions-cache-never-hits-on-pull-requests-branch-scope-immutable-keys-and-restore-keys-59bb"&gt;GitHub Actions Cache Never Hits on Pull Requests: Branch Scope, Immutable Keys, and restore-keys&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>programming</category>
      <category>tutorial</category>
      <category>backend</category>
    </item>
    <item>
      <title>LLM Provider Returns 529/503 Overloaded: LiteLLM vs OpenRouter vs a Hand-Rolled Fallback</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Fri, 13 Nov 2026 21:20:50 +0000</pubDate>
      <link>https://dev.to/libme/llm-provider-returns-529503-overloaded-litellm-vs-openrouter-vs-a-hand-rolled-fallback-1m63</link>
      <guid>https://dev.to/libme/llm-provider-returns-529503-overloaded-litellm-vs-openrouter-vs-a-hand-rolled-fallback-1m63</guid>
      <description>&lt;p&gt;If one provider's outage takes your feature down, you need a fallback path, and you have three realistic options. A hosted router such as OpenRouter is the least code but adds a third party to your request path. A self-hosted layer such as LiteLLM keeps keys and logs in your hands but is something you now operate. A 40-line wrapper around two SDKs is enough when you only ever call two models.&lt;/p&gt;

&lt;p&gt;The symptom that sends people searching for this looks like &lt;code&gt;529 overloaded_error&lt;/code&gt;, &lt;code&gt;503 Service Unavailable&lt;/code&gt;, or a stream that opens and then dies halfway. I'll walk through what to fix first, then compare the three approaches. Model names below are placeholders; substitute whatever you run as of November 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does retrying the same provider not fix a 529 or 503?
&lt;/h2&gt;

&lt;p&gt;Retries handle blips. Overload and regional incidents last minutes to hours, and a retry loop with backoff just queues your users behind the problem. Worse, if every worker retries, you add load to a provider that is already shedding it.&lt;/p&gt;

&lt;p&gt;The dead end I see most often: bumping &lt;code&gt;max_retries&lt;/code&gt; in the SDK from 2 to 10. That turns a fast failure into a 90-second hang, and your own request timeouts then fire first. Retries are for 429s with a &lt;code&gt;Retry-After&lt;/code&gt; header and for transient network errors. A different provider is the answer to "this one is down."&lt;/p&gt;

&lt;p&gt;Takeaway: retry for blips, fail over for outages, and never use the same mechanism for both.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should trigger a failover, and what should not?
&lt;/h2&gt;

&lt;p&gt;Before choosing a tool, decide the policy, because every option below is only as good as the rules you feed it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Error class&lt;/th&gt;
&lt;th&gt;Fail over?&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;5xx, 529, connection reset, timeout&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Provider-side or network problem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;429 rate limit&lt;/td&gt;
&lt;td&gt;Retry first, then fail over&lt;/td&gt;
&lt;td&gt;Often your own quota; the backup may have headroom&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;400 invalid request / context too long&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;The backup will likely reject it too, or silently truncate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;401/403&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Your key is wrong; failing over hides a config bug&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content-policy refusal&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Not an outage; behavior differs by model&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The row people get wrong is context length. A fallback model with a smaller context window will fail your longest requests, which are usually your most important ones. Check the window of every model in the chain before you ship it.&lt;/p&gt;

&lt;p&gt;Takeaway: a fallback chain that ignores 4xx semantics will mask your own bugs as provider flakiness.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does LiteLLM handle fallbacks?
&lt;/h2&gt;

&lt;p&gt;LiteLLM is an open-source Python library plus an optional proxy server that exposes an OpenAI-compatible API in front of many providers. Its &lt;code&gt;Router&lt;/code&gt; takes a list of deployments and a fallback map.&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;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;litellm&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;Router&lt;/span&gt;

&lt;span class="n"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Router&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model_list&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="s2"&gt;"model_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"chat"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s2"&gt;"litellm_params"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="s2"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"anthropic/claude-sonnet-4-5"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# placeholder&lt;/span&gt;
                &lt;span class="s2"&gt;"api_key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ANTHROPIC_API_KEY"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="s2"&gt;"model_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"chat-backup"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s2"&gt;"litellm_params"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="s2"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"openai/gpt-4o"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# placeholder&lt;/span&gt;
                &lt;span class="s2"&gt;"api_key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"OPENAI_API_KEY"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;fallbacks&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"chat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"chat-backup"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;num_retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;completion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"chat"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"Summarize this ticket."&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What it does well: keys never leave your infrastructure, you can set per-model timeouts and cooldowns, and the same code path covers dozens of providers. What tripped me up: the fallback fires per request, so during a long outage every request pays the primary's timeout first unless you configure cooldowns for failing deployments. Also, running the proxy server means you own its uptime, its database if you enable spend tracking, and its upgrades. If you want a self-hosted gateway with fallbacks, budgets, and one OpenAI-compatible endpoint for every team, LiteLLM is the one that gives you that without sending traffic through a third party.&lt;/p&gt;

&lt;p&gt;Takeaway: LiteLLM trades a service you rent for a service you run, and the timeout-before-fallback latency is the part to tune.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does OpenRouter handle fallbacks?
&lt;/h2&gt;

&lt;p&gt;OpenRouter is a hosted API that fronts many providers with one key and one billing relationship. You send an ordered list of models and it tries them in turn.&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;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://openrouter.ai/api/v1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"OPENROUTER_API_KEY"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"anthropic/claude-sonnet-4.5"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# placeholder&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"Summarize this ticket."&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;extra_body&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="s2"&gt;"models"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="s2"&gt;"anthropic/claude-sonnet-4.5"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s2"&gt;"openai/gpt-4o"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the least code, and one account replaces several provider invoices. The drawbacks are real, though. Your prompts and your users' data pass through one more company, which matters if your contracts or compliance posture name specific subprocessors. The router itself becomes a single point of failure, so you have traded N providers' outages for N providers' outages plus one. And pricing includes a platform layer; check the current fee model on their site rather than trusting my memory. If you want one key and one invoice and don't want to run anything, OpenRouter is the lowest-effort way to get multi-provider fallback.&lt;/p&gt;

&lt;p&gt;Takeaway: OpenRouter removes operational work and adds a dependency you cannot debug from the inside.&lt;/p&gt;

&lt;h2&gt;
  
  
  When is a hand-rolled fallback enough?
&lt;/h2&gt;

&lt;p&gt;If you call exactly two models and the request shapes are simple, a wrapper is shorter than the config for either tool. The key is classifying errors, not looping blindly.&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;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt;

&lt;span class="n"&gt;primary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;backup&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&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="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"claude-sonnet-4-5"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# placeholder&lt;/span&gt;
            &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;APIConnectionError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;APITimeoutError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;anthropic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;InternalServerError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;pass&lt;/span&gt;  &lt;span class="c1"&gt;# 5xx, 529, network, timeout: fail over&lt;/span&gt;
    &lt;span class="c1"&gt;# 4xx (BadRequestError, AuthenticationError) propagate on purpose&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;backup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"gpt-4o"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# placeholder&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The limitation: it does not scale. The third provider, tool calling with different schemas, and streaming mid-failure each add code, and that is the point where one of the two tools above starts paying for itself. Also note that a stream that dies halfway cannot be transparently resumed on another model; decide up front whether you discard the partial output and restart.&lt;/p&gt;

&lt;p&gt;Takeaway: two models and no tool calls means write the wrapper; anything more and you are rebuilding a gateway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;LiteLLM (self-hosted)&lt;/th&gt;
&lt;th&gt;OpenRouter&lt;/th&gt;
&lt;th&gt;Hand-rolled&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Keys and prompts stay with you&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ops burden&lt;/td&gt;
&lt;td&gt;Medium (proxy, DB optional)&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Low, grows with providers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Added failure point&lt;/td&gt;
&lt;td&gt;Your own proxy&lt;/td&gt;
&lt;td&gt;Their service&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Providers supported&lt;/td&gt;
&lt;td&gt;Many&lt;/td&gt;
&lt;td&gt;Many&lt;/td&gt;
&lt;td&gt;Whatever you code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Platform teams, budgets&lt;/td&gt;
&lt;td&gt;Prototypes, small teams&lt;/td&gt;
&lt;td&gt;Two-model apps&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Should I retry or fail over when my LLM API returns 529 overloaded?
&lt;/h3&gt;

&lt;p&gt;Retry once with a short backoff, then fail over. Repeated retries during an overload extend the outage for your users and add load to the provider.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does failing over to a different model change my output quality?
&lt;/h3&gt;

&lt;p&gt;Yes. Prompts tuned for one model often behave differently on another, so run your evaluation set against the backup model before you rely on it in production.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I resume a streamed LLM response on another provider after it fails midway?
&lt;/h3&gt;

&lt;p&gt;Not transparently. The backup model has no knowledge of the partial output, so you either restart the request or pass the partial text as context and accept a visible seam.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;If you call two models and want to keep control, write the wrapper and classify errors carefully. If you are a platform team that needs shared keys, budgets, and many providers behind one endpoint, run LiteLLM. If you are a small team that wants one key and zero operations, accept the extra dependency and use OpenRouter. Whichever you choose, test the fallback by blocking the primary's hostname in staging, because an untested failover path is the one that fails during the incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/your-postgres-migration-runner-needs-a-retry-contract-not-just-a-lock-timeout-1oii"&gt;Your Postgres Migration Runner Needs a Retry Contract, Not Just a Lock Timeout&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/ship-a-production-rag-chatbot-in-a-weekend-with-claude-pgvector-and-fastapi-3aeo"&gt;Ship a Production RAG Chatbot in a Weekend with Claude, pgvector, and FastAPI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/cursor-vs-github-copilot-vs-windsurf-which-ai-editor-fits-how-you-actually-work-1ih9"&gt;Cursor vs GitHub Copilot vs Windsurf: Which AI Editor Fits How You Actually Work?&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>llm</category>
      <category>ai</category>
      <category>api</category>
      <category>backend</category>
    </item>
    <item>
      <title>SQLAlchemy MissingGreenlet in Async Code: Fix Lazy Loading With selectinload, awaitable_attrs, or lazy="raise"</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Thu, 12 Nov 2026 21:57:00 +0000</pubDate>
      <link>https://dev.to/libme/sqlalchemy-missinggreenlet-in-async-code-fix-lazy-loading-with-selectinload-awaitableattrs-or-nko</link>
      <guid>https://dev.to/libme/sqlalchemy-missinggreenlet-in-async-code-fix-lazy-loading-with-selectinload-awaitableattrs-or-nko</guid>
      <description>&lt;p&gt;&lt;code&gt;sqlalchemy.exc.MissingGreenlet: greenlet_spawn has not been called; can't call await_only() here&lt;/code&gt; means you touched a relationship or expired column that wasn't loaded, and SQLAlchemy tried to run a lazy-load query from synchronous attribute access inside an async session. The durable fix is to decide at query time which relationships you need (&lt;code&gt;selectinload&lt;/code&gt; or &lt;code&gt;joinedload&lt;/code&gt;) instead of letting attribute access trigger I/O. Use &lt;code&gt;awaitable_attrs&lt;/code&gt; for the occasional one-off, and &lt;code&gt;lazy="raise"&lt;/code&gt; to turn the bug into a loud, early failure. (Written against SQLAlchemy 2.0.x as of late 2026.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does MissingGreenlet happen?
&lt;/h2&gt;

&lt;p&gt;In a sync session, &lt;code&gt;author.books&lt;/code&gt; on an unloaded relationship quietly emits a SELECT and returns the list. That works because attribute access is a plain Python call and the driver call blocks.&lt;/p&gt;

&lt;p&gt;In an async session, the driver call must be awaited, but &lt;code&gt;author.books&lt;/code&gt; is not awaitable. SQLAlchemy's async layer runs sync-style ORM code inside a greenlet so that it can bridge to &lt;code&gt;await&lt;/code&gt;. If you trigger the lazy load outside that bridge, for example in your route handler after the query returned, there is no greenlet to switch to and you get &lt;code&gt;MissingGreenlet&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here is a minimal reproduction:&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;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;ForeignKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;select&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;asyncio&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;AsyncAttrs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;async_sessionmaker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;create_async_engine&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orm&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nc"&gt;DeclarativeBase&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;relationship&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AsyncAttrs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;DeclarativeBase&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;pass&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Author&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;__tablename__&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"author"&lt;/span&gt;
    &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;primary_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;books&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Book"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;relationship&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;back_populates&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"author"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Book&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;__tablename__&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"book"&lt;/span&gt;
    &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;primary_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;author_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mapped_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;ForeignKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"author.id"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Author&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;relationship&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;back_populates&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"books"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_async_engine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"sqlite+aiosqlite:///:memory:"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nc"&gt;Session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;async_sessionmaker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;engine&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;main&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;begin&lt;/span&gt;&lt;span class="p"&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;conn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_sync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create_all&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;with&lt;/span&gt; &lt;span class="nc"&gt;Session&lt;/span&gt;&lt;span class="p"&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;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Author&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Ada"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;books&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;Book&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Notes"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;(&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;with&lt;/span&gt; &lt;span class="nc"&gt;Session&lt;/span&gt;&lt;span class="p"&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;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;author&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Author&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;books&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# MissingGreenlet&lt;/span&gt;


&lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(This needs &lt;code&gt;aiosqlite&lt;/code&gt; installed. Any async driver reproduces it.)&lt;/p&gt;

&lt;p&gt;What tripped me up the first time: the failing line looks completely innocent. Nothing in it says "database". The query was written ten lines earlier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: in async SQLAlchemy, every attribute access that might hit the database must be loaded before you read it.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What are the three ways to fix it?
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Eager-load at query time (default choice)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;orm&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="n"&gt;selectinload&lt;/span&gt;

&lt;span class="n"&gt;stmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Author&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;options&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;selectinload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;books&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;author&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stmt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scalars&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;one&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;books&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# no extra I/O&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;selectinload&lt;/code&gt; issues one additional SELECT with an &lt;code&gt;IN&lt;/code&gt; list of parent keys, which avoids the row duplication that a join creates for collections. &lt;code&gt;joinedload&lt;/code&gt; fetches everything in one statement and is a better fit for many-to-one relationships like &lt;code&gt;Book.author&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. &lt;code&gt;awaitable_attrs&lt;/code&gt; for one-off access
&lt;/h3&gt;

&lt;p&gt;Because &lt;code&gt;Base&lt;/code&gt; mixes in &lt;code&gt;AsyncAttrs&lt;/code&gt;, you can await a lazy load explicitly:&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;books&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;awaitable_attrs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;books&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is honest about the I/O, which is nice. The drawback is that inside a loop it becomes an N+1 query pattern that is easy to write and hard to spot in review.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. &lt;code&gt;lazy="raise"&lt;/code&gt; to make the mistake impossible to miss
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;books&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Mapped&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Book"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;relationship&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;back_populates&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"author"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lazy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"raise"&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now any unloaded access raises &lt;code&gt;InvalidRequestError&lt;/code&gt; naming the attribute, rather than a vague greenlet message, and it fires in tests instead of in one rarely used endpoint. You opt in per query with &lt;code&gt;selectinload&lt;/code&gt; or &lt;code&gt;joinedload&lt;/code&gt;. The cost is some boilerplate on every query that needs the relationship.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Extra queries&lt;/th&gt;
&lt;th&gt;Fails loudly if forgotten&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;selectinload&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1 per relationship&lt;/td&gt;
&lt;td&gt;Only with &lt;code&gt;lazy="raise"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Collections (one-to-many, many-to-many)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;joinedload&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0 (one JOIN)&lt;/td&gt;
&lt;td&gt;Only with &lt;code&gt;lazy="raise"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Many-to-one, single related row&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;awaitable_attrs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1 per access&lt;/td&gt;
&lt;td&gt;n/a (explicit)&lt;/td&gt;
&lt;td&gt;Scripts, rare branches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;lazy="raise"&lt;/code&gt; default&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Teams that want N+1 caught in tests&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: pick loading strategy per query, and set &lt;code&gt;lazy="raise"&lt;/code&gt; on the model so forgetting is a test failure rather than a production 500.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do I also get MissingGreenlet after commit()?
&lt;/h2&gt;

&lt;p&gt;Same error, different trigger. By default &lt;code&gt;commit()&lt;/code&gt; expires every loaded attribute on every object in the session. The next time you read &lt;code&gt;author.name&lt;/code&gt;, SQLAlchemy wants to refresh it with a SELECT, which fails outside the greenlet bridge:&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;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nc"&gt;Session&lt;/span&gt;&lt;span class="p"&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;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;author&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Author&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Grace"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# MissingGreenlet: attribute was expired by commit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two fixes. Either configure the sessionmaker so objects stay usable after commit:&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="nc"&gt;Session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;async_sessionmaker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expire_on_commit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or call &lt;code&gt;await session.refresh(author)&lt;/code&gt; when you really need fresh values. With &lt;code&gt;expire_on_commit=False&lt;/code&gt; the trade-off is that your in-memory objects can go stale relative to the database (server-side defaults and triggers won't show up until you refresh). For typical request-scoped sessions that live for one HTTP request, I have found that acceptable, but it is a real trade-off, not a free setting.&lt;/p&gt;

&lt;p&gt;A related mistake: returning ORM objects from a handler after the session closes and letting a serializer walk the relationships. The serializer triggers the lazy load on a detached object. Convert to plain schemas inside the session scope, or eager-load everything the serializer will touch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: &lt;code&gt;commit()&lt;/code&gt; expires attributes by default, so &lt;code&gt;expire_on_commit=False&lt;/code&gt; is the standard async setting, with staleness as the price.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I debug which attribute triggered it?
&lt;/h2&gt;

&lt;p&gt;The traceback usually points at the access site, but not at which relationship. Three quick moves:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Temporarily set &lt;code&gt;lazy="raise"&lt;/code&gt; on all relationships. The exception names the exact attribute.&lt;/li&gt;
&lt;li&gt;Turn on &lt;code&gt;echo=True&lt;/code&gt; on the engine and look for a SELECT that appears where you expected none.&lt;/li&gt;
&lt;li&gt;Check whether the object is coming from a different session, such as one cached or passed between tasks. Sessions are not safe to share across concurrent tasks.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you use SQLModel or a FastAPI template that wraps SQLAlchemy, the same rules apply, since they sit on top of the same async layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why does SQLAlchemy say "greenlet_spawn has not been called"?
&lt;/h3&gt;

&lt;p&gt;You accessed an unloaded attribute that requires a database query from synchronous code inside an async session. Eager-load the attribute with &lt;code&gt;selectinload&lt;/code&gt; or &lt;code&gt;joinedload&lt;/code&gt;, or await it through &lt;code&gt;awaitable_attrs&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use selectinload or joinedload with async SQLAlchemy?
&lt;/h3&gt;

&lt;p&gt;Use &lt;code&gt;selectinload&lt;/code&gt; for collections and &lt;code&gt;joinedload&lt;/code&gt; for many-to-one relationships. Both work in async sessions because the loading happens inside the awaited &lt;code&gt;execute()&lt;/code&gt; call.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is expire_on_commit=False safe?
&lt;/h3&gt;

&lt;p&gt;It is safe for short-lived, request-scoped sessions. It means objects keep their pre-commit values, so call &lt;code&gt;await session.refresh(obj)&lt;/code&gt; when you need database-generated values.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;If you are writing async SQLAlchemy, set &lt;code&gt;lazy="raise"&lt;/code&gt; on relationships and &lt;code&gt;expire_on_commit=False&lt;/code&gt; on the sessionmaker, then choose &lt;code&gt;selectinload&lt;/code&gt; or &lt;code&gt;joinedload&lt;/code&gt; explicitly in each query. Reach for &lt;code&gt;awaitable_attrs&lt;/code&gt; in scripts and rare branches, not in loops. If your team is small and tests are thin, the &lt;code&gt;lazy="raise"&lt;/code&gt; default is the single change that pays back fastest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/pip-externally-managed-environment-error-venv-pipx-or-uv-12e2"&gt;pip "externally-managed-environment" Error: venv, pipx, or uv?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/pgbouncer-vs-supavisor-vs-rds-proxy-which-postgres-pooler-survives-transaction-mode-1l52"&gt;PgBouncer vs Supavisor vs RDS Proxy: Which Postgres Pooler Survives Transaction Mode?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/why-your-oauth-integration-randomly-returns-invalidgrant-and-how-to-stop-two-workers-from-racing-4ake"&gt;Why Your OAuth Integration Randomly Returns invalid_grant (and How to Stop Two Workers From Racing)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>database</category>
      <category>sql</category>
      <category>backend</category>
    </item>
    <item>
      <title>release-please vs Changesets vs semantic-release: Which Release Automation Fits a Solo Dev or Small Team?</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Wed, 11 Nov 2026 18:32:09 +0000</pubDate>
      <link>https://dev.to/libme/release-please-vs-changesets-vs-semantic-release-which-release-automation-fits-a-solo-dev-or-small-4c85</link>
      <guid>https://dev.to/libme/release-please-vs-changesets-vs-semantic-release-which-release-automation-fits-a-solo-dev-or-small-4c85</guid>
      <description>&lt;p&gt;If you merge mostly through squashed pull requests on GitHub, release-please is the lowest-friction choice: it opens a "release PR" you merge when you want to ship. If you maintain a monorepo with several packages, Changesets fits better because humans declare the bump. If you want zero human steps after merge and your team writes disciplined commit messages, semantic-release does that, at the cost of a plugin-heavy config.&lt;/p&gt;

&lt;p&gt;All three solve the same problem: stop hand-editing version numbers and changelogs. They differ in &lt;em&gt;where the decision to bump lives&lt;/em&gt;, and that is what you should pick on. (Behavior described as of late 2026; check each project's docs before copying config.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does the version bump decision come from?
&lt;/h2&gt;

&lt;p&gt;This is the whole comparison in one question.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Bump decided by&lt;/th&gt;
&lt;th&gt;Release trigger&lt;/th&gt;
&lt;th&gt;Human gate before publish&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;release-please&lt;/td&gt;
&lt;td&gt;Conventional Commit messages&lt;/td&gt;
&lt;td&gt;Merging an auto-maintained release PR&lt;/td&gt;
&lt;td&gt;Yes, the release PR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Changesets&lt;/td&gt;
&lt;td&gt;Markdown "changeset" files committed with each PR&lt;/td&gt;
&lt;td&gt;Merging a "Version Packages" PR&lt;/td&gt;
&lt;td&gt;Yes, the version PR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;semantic-release&lt;/td&gt;
&lt;td&gt;Conventional Commit messages&lt;/td&gt;
&lt;td&gt;Every push to a release branch&lt;/td&gt;
&lt;td&gt;No, fully automatic&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The consequence people miss: the commit-message tools (release-please, semantic-release) move the discipline into the &lt;em&gt;commit subject line&lt;/em&gt;. If your team squash-merges and the PR title becomes the commit, then the PR title is your release note. A sloppy title is a sloppy changelog, and no tool fixes that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: pick the tool whose input you are most willing to be careful about: commit subjects, changeset files, or nothing at all.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How does release-please work in practice?
&lt;/h2&gt;

&lt;p&gt;It watches your default branch, reads commits since the last release, and keeps one open PR that bumps the version and updates &lt;code&gt;CHANGELOG.md&lt;/code&gt;. Merging that PR creates the git tag and GitHub release. Nothing ships until you merge it, which is why I reach for it on side projects: batching is free.&lt;/p&gt;

&lt;p&gt;A minimal GitHub Actions workflow:&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="c1"&gt;# .github/workflows/release-please.yml
&lt;/span&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;release-please&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;main&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
  &lt;span class="na"&gt;pull-requests&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;release-please&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;googleapis/release-please-action@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;release-type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;node&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The permissions block matters. Without &lt;code&gt;pull-requests: write&lt;/code&gt; the action fails when it tries to open the release PR, and in repos where the default &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; is read-only you will see a permissions error rather than a helpful hint. If this bites you, set the permissions explicitly as above.&lt;/p&gt;

&lt;p&gt;What tripped me up: release-please does not publish to npm or PyPI by itself. It cuts the tag and release; you add a second job conditioned on the action's &lt;code&gt;release_created&lt;/code&gt; output to run &lt;code&gt;npm publish&lt;/code&gt;. Also, commits that don't follow Conventional Commits (&lt;code&gt;feat:&lt;/code&gt;, &lt;code&gt;fix:&lt;/code&gt;, &lt;code&gt;feat!:&lt;/code&gt;) are simply ignored for versioning, so a repo with messy history produces an empty release PR, or none.&lt;/p&gt;

&lt;p&gt;If you want the version bump to come from a reviewable PR you merge on your own schedule, release-please is the one that does that with the least config.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: release-please trades full automation for a merge button, and for small teams that button is the feature.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When is Changesets the better fit?
&lt;/h2&gt;

&lt;p&gt;Changesets inverts the model. Each PR that should affect a release includes a small markdown file created with &lt;code&gt;npx changeset&lt;/code&gt;, stating which packages change and whether it is a patch, minor, or major, plus a human-written summary.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;---
"@acme/ui": minor
"@acme/utils": patch
---

Add &lt;span class="sb"&gt;`size`&lt;/span&gt; prop to Button; fix off-by-one in &lt;span class="sb"&gt;`chunk()`&lt;/span&gt;.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A bot then opens a "Version Packages" PR that consumes those files, bumps versions (including dependents in the workspace), and writes changelogs. This is why it dominates JavaScript monorepos: bumping &lt;code&gt;@acme/utils&lt;/code&gt; can automatically bump packages that depend on it, and you decide per package rather than per commit.&lt;/p&gt;

&lt;p&gt;The drawbacks are real. Every contributor has to remember to add a changeset, and forgetting is the common failure; you can enforce it with a CI check, but that is more setup. It is also JavaScript-centric. You can bend it to other ecosystems, but that is not its home turf. For a single-package repo it is more ceremony than the problem deserves.&lt;/p&gt;

&lt;p&gt;For a multi-package workspace where the person writing the change knows best what the bump should be, Changesets is the tool that gives you that control without parsing commit messages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: Changesets wins when the bump is a human judgment per package, and loses when nobody remembers to write the file.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Is semantic-release too much for a small project?
&lt;/h2&gt;

&lt;p&gt;semantic-release publishes on every qualifying push: it analyzes commits, picks the version, tags, writes release notes, and publishes through plugins. There is no PR to merge. A basic &lt;code&gt;.releaserc.json&lt;/code&gt;:&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="nl"&gt;"branches"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"main"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nl"&gt;"plugins"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s2"&gt;"@semantic-release/commit-analyzer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"@semantic-release/release-notes-generator"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"@semantic-release/changelog"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"@semantic-release/npm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"@semantic-release/github"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"@semantic-release/git"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;"assets"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"CHANGELOG.md"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"package.json"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="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;The upside is that releases are boring: merge, and it ships. The downside is the same sentence: merge, and it ships. A mistyped &lt;code&gt;feat!:&lt;/code&gt; publishes a major version before anyone reviews it, and unpublishing from a public registry is restricted, so you generally cannot take it back cleanly. It also needs a token with permission to push the release commit back, which collides with branch protection rules; I have seen that setup consume an afternoon.&lt;/p&gt;

&lt;p&gt;If you want fully automatic publishing and you trust your commit hygiene (for example via a commit-lint check in CI), semantic-release is the one that removes the last human step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: semantic-release is the right amount of automation only if a wrong release is cheap to recover from.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision table: which one for which situation?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your situation&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;th&gt;Reason&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Single package, squash-merge PRs, GitHub&lt;/td&gt;
&lt;td&gt;release-please&lt;/td&gt;
&lt;td&gt;Release PR gives a free review step&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Monorepo with interdependent packages&lt;/td&gt;
&lt;td&gt;Changesets&lt;/td&gt;
&lt;td&gt;Per-package, human-declared bumps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Library users depend on instant fixes&lt;/td&gt;
&lt;td&gt;semantic-release&lt;/td&gt;
&lt;td&gt;No batching delay&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Team with inconsistent commit messages&lt;/td&gt;
&lt;td&gt;Changesets&lt;/td&gt;
&lt;td&gt;Doesn't depend on commit text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Non-JavaScript repo (Python, Go, etc.)&lt;/td&gt;
&lt;td&gt;release-please&lt;/td&gt;
&lt;td&gt;Supports several release types&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do I need Conventional Commits to automate releases?&lt;/strong&gt;&lt;br&gt;
Only for release-please and semantic-release, which read commit messages to decide the bump. Changesets does not use commit messages at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can release-please publish to npm automatically?&lt;/strong&gt;&lt;br&gt;
Not by itself. It creates the tag and GitHub release, and you add a follow-up job gated on its &lt;code&gt;release_created&lt;/code&gt; output to run the publish step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why did my release PR not appear?&lt;/strong&gt;&lt;br&gt;
Almost always because no commit since the last release has a &lt;code&gt;feat:&lt;/code&gt; or &lt;code&gt;fix:&lt;/code&gt; type, or because the workflow lacks &lt;code&gt;pull-requests: write&lt;/code&gt; permission.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;For a solo developer or a two-to-five person team on a single package, start with release-please and enforce squash merges with clear PR titles. Move to Changesets when you have a workspace with several packages whose versions need independent control. Choose semantic-release only when you want hands-off publishing and have commit linting in place so a typo cannot ship a major version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/hotstandbyfeedback-for-a-reporting-replica-query-cancellations-or-primary-bloat-mek"&gt;hot_standby_feedback for a Reporting Replica: Query Cancellations or Primary Bloat?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/zapier-vs-make-vs-n8n-when-paying-per-task-stops-making-sense-327b"&gt;Zapier vs Make vs n8n: When Paying Per Task Stops Making Sense&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/your-llm-bill-jumped-after-you-added-context-find-the-cache-miss-before-you-downgrade-the-model-31io"&gt;Your LLM Bill Jumped After You Added Context: Find the Cache Miss Before You Downgrade the Model&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>github</category>
      <category>cicd</category>
      <category>automation</category>
    </item>
    <item>
      <title>Postgres Full-Text Search vs Meilisearch vs Typesense: Do You Need a Search Service for a Small App?</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Mon, 09 Nov 2026 21:59:04 +0000</pubDate>
      <link>https://dev.to/libme/postgres-full-text-search-vs-meilisearch-vs-typesense-do-you-need-a-search-service-for-a-small-app-5hd2</link>
      <guid>https://dev.to/libme/postgres-full-text-search-vs-meilisearch-vs-typesense-do-you-need-a-search-service-for-a-small-app-5hd2</guid>
      <description>&lt;p&gt;If your app already runs on Postgres and you need a search box over a few hundred thousand rows, start with Postgres full-text search plus &lt;code&gt;pg_trgm&lt;/code&gt;. Add Meilisearch or Typesense when you need typo tolerance as-you-type, faceted filtering with instant counts, or relevance tuning that non-engineers can adjust. Until one of those is a real requirement, a second datastore is mostly a second thing to keep in sync.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does Postgres full-text search actually give you?
&lt;/h2&gt;

&lt;p&gt;More than people assume. Stemming, stop words, weighted fields, phrase search, ranking, and a &lt;code&gt;websearch_to_tsquery&lt;/code&gt; parser that understands quoted phrases and &lt;code&gt;-exclusions&lt;/code&gt; the way users type them. As of late 2026 this is all core Postgres, no extension needed.&lt;/p&gt;

&lt;p&gt;A setup that works well for a typical &lt;code&gt;articles&lt;/code&gt; table:&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="k"&gt;search&lt;/span&gt; &lt;span class="n"&gt;tsvector&lt;/span&gt;
&lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;setweight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to_tsvector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="s1"&gt;'A'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
  &lt;span class="n"&gt;setweight&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to_tsvector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="s1"&gt;'B'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;STORED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;articles_search_idx&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;GIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;search&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ts_rank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;search&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;websearch_to_tsquery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'english'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'postgres "connection pool" -mysql'&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;q&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;search&lt;/span&gt; &lt;span class="o"&gt;@@&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;rank&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generated column keeps the index in sync with the row inside the same transaction. That is the real advantage over an external service: there is no indexing pipeline that can fall behind or silently drop a document.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: Postgres full-text search gives you transactionally consistent, weighted, stemmed search with zero new infrastructure.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does Postgres search fall short?
&lt;/h2&gt;

&lt;p&gt;Three places, and they are the ones users notice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Typos.&lt;/strong&gt; Searching &lt;code&gt;postgre&lt;/code&gt; or &lt;code&gt;kuberentes&lt;/code&gt; returns nothing, because full-text search matches lexemes, not approximate strings. The standard patch is &lt;code&gt;pg_trgm&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;pg_trgm&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;articles_title_trgm&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;GIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="n"&gt;gin_trgm_ops&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;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;similarity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'kuberentes'&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;sim&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;articles&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="s1"&gt;'kuberentes'&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;sim&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works for short fields like titles and names. It is a poor fit for fuzzy matching inside long bodies, and tuning &lt;code&gt;pg_trgm.similarity_threshold&lt;/code&gt; is something you do by trial and error.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ranking quality.&lt;/strong&gt; &lt;code&gt;ts_rank&lt;/code&gt; does not account for how rare a term is across the corpus in the way BM25-style engines do. For small, homogeneous data you may never care. For mixed content, results can feel arbitrary, and the fix is hand-written boosts in SQL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Faceting.&lt;/strong&gt; Counting results per category for every query means a &lt;code&gt;GROUP BY&lt;/code&gt; over the whole match set. That is fine at tens of thousands of matches and gets slow beyond that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: Postgres fails at the user-facing polish (typos, ranking, facet counts), not at the correctness of matching.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do Meilisearch and Typesense differ?
&lt;/h2&gt;

&lt;p&gt;Both are single-binary, open-source search engines aimed at the "instant search box" use case, both ship typo tolerance on by default, and both have hosted cloud offerings if you do not want to run them. The differences that matter in practice:&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;Postgres FTS + pg_trgm&lt;/th&gt;
&lt;th&gt;Meilisearch&lt;/th&gt;
&lt;th&gt;Typesense&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;New infrastructure&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;One service&lt;/td&gt;
&lt;td&gt;One service (cluster for HA)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typo tolerance&lt;/td&gt;
&lt;td&gt;Only via &lt;code&gt;pg_trgm&lt;/code&gt;, manual&lt;/td&gt;
&lt;td&gt;Built in&lt;/td&gt;
&lt;td&gt;Built in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Index lives in&lt;/td&gt;
&lt;td&gt;Your database&lt;/td&gt;
&lt;td&gt;Disk (memory-mapped)&lt;/td&gt;
&lt;td&gt;RAM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Consistency with your DB&lt;/td&gt;
&lt;td&gt;Transactional&lt;/td&gt;
&lt;td&gt;Eventually, you sync it&lt;/td&gt;
&lt;td&gt;Eventually, you sync it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Facets / filters&lt;/td&gt;
&lt;td&gt;Hand-written SQL&lt;/td&gt;
&lt;td&gt;Built in&lt;/td&gt;
&lt;td&gt;Built in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ranking control&lt;/td&gt;
&lt;td&gt;SQL expressions&lt;/td&gt;
&lt;td&gt;Ordered ranking rules&lt;/td&gt;
&lt;td&gt;Per-field weights, sort expressions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Main drawback&lt;/td&gt;
&lt;td&gt;Polish, tuning effort&lt;/td&gt;
&lt;td&gt;Another sync pipeline&lt;/td&gt;
&lt;td&gt;RAM sizing, another sync pipeline&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Typesense holding its index in RAM is why it is fast, and it is also the cost driver: your dataset size becomes your memory bill. Meilisearch is lighter on RAM for the same corpus in my experience, but check the current license terms for any specific feature you depend on, since parts of the ecosystem have moved between licenses over time.&lt;/p&gt;

&lt;p&gt;If you want the managed, zero-ops version of instant search with typo tolerance, Meilisearch Cloud is the option that removes the hosting question while keeping the engine simple. If you want predictable low-latency lookups and your dataset comfortably fits in memory, Typesense is the one built around that assumption.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: Meilisearch and Typesense buy you polish, and the price is a sync pipeline plus one more thing to operate.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the hidden cost of adding a search service?
&lt;/h2&gt;

&lt;p&gt;Keeping it in sync. Every create, update, and delete in Postgres must reach the engine, and the failure mode is quiet: a deleted record that still shows up in results, or a new one that never appears.&lt;/p&gt;

&lt;p&gt;The minimum viable pattern is an outbox table written in the same transaction as the change, drained by a worker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;search_outbox&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;bigserial&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;article_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&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;span class="n"&gt;op&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;op&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'upsert'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'delete'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&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;The worker reads in batches, pushes to the engine, then deletes the processed rows. You also need a periodic full reindex, because you will eventually find drift. That is roughly a day of work plus ongoing monitoring, which is the honest comparison against the afternoon Postgres FTS takes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: The search engine is cheap; the sync pipeline and its drift detection are what you actually pay for.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one should you pick?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your situation&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Under roughly a few hundred thousand rows, users search by exact-ish terms&lt;/td&gt;
&lt;td&gt;Postgres FTS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Short fields (names, titles) where typos matter&lt;/td&gt;
&lt;td&gt;Postgres FTS + &lt;code&gt;pg_trgm&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Search-as-you-type with typo tolerance is a product requirement&lt;/td&gt;
&lt;td&gt;Meilisearch or Typesense&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Many facets with live counts&lt;/td&gt;
&lt;td&gt;Meilisearch or Typesense&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dataset is small enough to fit in RAM and latency is critical&lt;/td&gt;
&lt;td&gt;Typesense&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You have no one to operate another service&lt;/td&gt;
&lt;td&gt;Postgres, or a managed cloud plan&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These size boundaries are rules of thumb, not benchmarks. Measure your own query latency with &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt; before deciding the database is the bottleneck.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is Postgres full-text search good enough for production?&lt;/strong&gt;&lt;br&gt;
Yes, for keyword search over moderate datasets it is production-grade, with GIN indexes and weighted ranking. It becomes a poor fit when you need typo tolerance in long text or live facet counts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I add typo tolerance to Postgres search?&lt;/strong&gt;&lt;br&gt;
Enable the &lt;code&gt;pg_trgm&lt;/code&gt; extension, add a GIN trigram index on the field, and query with the &lt;code&gt;%&lt;/code&gt; similarity operator. It works best on short fields like titles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Meilisearch or Typesense: which is easier to start with?&lt;/strong&gt;&lt;br&gt;
Both run as a single binary and take minutes to try locally. Meilisearch is usually lighter on memory, while Typesense keeps its index in RAM, so size your server to your data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;If you already have Postgres and a search box, use &lt;code&gt;tsvector&lt;/code&gt; with a GIN index and add &lt;code&gt;pg_trgm&lt;/code&gt; for names and titles; you can ship that in an afternoon and it stays consistent with your data. Move to Meilisearch or Typesense when typo-tolerant instant search or faceting becomes something users complain about, and budget for the outbox and reindex job from day one. Pick the engine after you have measured what your data size and latency actually need.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/postman-vs-bruno-vs-hoppscotch-does-your-api-client-really-need-a-cloud-account-34bk"&gt;Postman vs Bruno vs Hoppscotch: Does Your API Client Really Need a Cloud Account?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/where-should-a-small-team-store-its-secrets-comparing-env-files-sops-doppler-1password-and-e13"&gt;Where Should a Small Team Store Its Secrets? Comparing .env Files, SOPS, Doppler, 1Password, and Vault&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/your-cache-expired-and-the-database-spiked-four-fixes-for-cache-stampedes-3fh5"&gt;Your Cache Expired and the Database Spiked: Four Fixes for Cache Stampedes&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>tooling</category>
      <category>architecture</category>
    </item>
    <item>
      <title>GitHub Actions "Resource not accessible by integration": Fix It With the Smallest GITHUB_TOKEN Permission</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Sun, 08 Nov 2026 21:54:39 +0000</pubDate>
      <link>https://dev.to/libme/github-actions-resource-not-accessible-by-integration-fix-it-with-the-smallest-githubtoken-2gk7</link>
      <guid>https://dev.to/libme/github-actions-resource-not-accessible-by-integration-fix-it-with-the-smallest-githubtoken-2gk7</guid>
      <description>&lt;p&gt;The error means the &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; your job runs with lacks the permission for the API call it just made. Fix it by adding a &lt;code&gt;permissions:&lt;/code&gt; block to the specific job with only the scope that call needs (for example &lt;code&gt;pull-requests: write&lt;/code&gt; to comment on a PR). Don't flip the whole repository to "read and write" just to make the red X go away.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the error actually look like?
&lt;/h2&gt;

&lt;p&gt;Depending on the action or CLI, you will see one of these in the job log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HttpError: Resource not accessible by integration
RequestError [HttpError]: Resource not accessible by integration - https://docs.github.com/rest/...
remote: Permission to org/repo.git denied to github-actions[bot].
fatal: unable to access 'https://github.com/org/repo/': The requested URL returned error: 403
gh: Resource not accessible by integration (HTTP 403)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All of them come down to the same thing: GitHub authenticated the request as the workflow's app token, and that token's permission set did not include what the endpoint requires. It is an authorization problem, not a bad token, so regenerating secrets will not help.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: a 403 from &lt;code&gt;github-actions[bot]&lt;/code&gt; is a missing permission scope, never an expired credential.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why did it work last month and fail now?
&lt;/h2&gt;

&lt;p&gt;Three things commonly change underneath you.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Default token permissions.&lt;/strong&gt; Each repository (and organization) has a setting under Settings → Actions → General → Workflow permissions. Newer repositories default to read-only for the token; older ones may still have the permissive default. A repo created or migrated after the default changed behaves differently from your old one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An explicit &lt;code&gt;permissions:&lt;/code&gt; key.&lt;/strong&gt; The moment a workflow or job declares &lt;em&gt;any&lt;/em&gt; permission, every scope you did not list is set to &lt;code&gt;none&lt;/code&gt;. Adding &lt;code&gt;id-token: write&lt;/code&gt; for a cloud login quietly removes &lt;code&gt;contents: read&lt;/code&gt;, and then &lt;code&gt;actions/checkout&lt;/code&gt; fails on a private repo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Who triggered the run.&lt;/strong&gt; Workflows triggered by a pull request from a fork get a read-only token, and by default no secrets. Runs triggered by Dependabot also get a read-only token by default. No &lt;code&gt;permissions:&lt;/code&gt; block can widen that for a &lt;code&gt;pull_request&lt;/code&gt; event from a fork.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: the same YAML can get different tokens depending on repo defaults, the trigger, and the PR author.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Which permission does each step need?
&lt;/h2&gt;

&lt;p&gt;This is the part the error message never tells you. As of late 2026, these are the scopes I reach for most:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What the step does&lt;/th&gt;
&lt;th&gt;Permission needed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;actions/checkout&lt;/code&gt; on a private repo&lt;/td&gt;
&lt;td&gt;&lt;code&gt;contents: read&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Push a commit, create a tag or release&lt;/td&gt;
&lt;td&gt;&lt;code&gt;contents: write&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Comment on or label a pull request&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pull-requests: write&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Create or comment on an issue&lt;/td&gt;
&lt;td&gt;&lt;code&gt;issues: write&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Push an image to GitHub Container Registry&lt;/td&gt;
&lt;td&gt;&lt;code&gt;packages: write&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Request an OIDC token for cloud login&lt;/td&gt;
&lt;td&gt;&lt;code&gt;id-token: write&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upload SARIF to code scanning&lt;/td&gt;
&lt;td&gt;&lt;code&gt;security-events: write&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy to GitHub Pages&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pages: write&lt;/code&gt; and &lt;code&gt;id-token: write&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write commit statuses or check runs&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;statuses: write&lt;/code&gt; / &lt;code&gt;checks: write&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One trap: PR comments go through the issues API under the hood, so on some endpoints &lt;code&gt;issues: write&lt;/code&gt; also works, but &lt;code&gt;pull-requests: write&lt;/code&gt; is the one that matches intent and is what most actions document.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I scope it to one job?
&lt;/h2&gt;

&lt;p&gt;Set the default low at the top of the workflow, then raise it only in the job that needs it:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pr-size-comment&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pull_request&lt;/span&gt;

&lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;comment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;contents&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read&lt;/span&gt;
      &lt;span class="na"&gt;pull-requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;write&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Post a comment&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;GH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.token }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;gh pr comment "${{ github.event.pull_request.number }}" \&lt;/span&gt;
            &lt;span class="s"&gt;--body "Checked by CI."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The workflow-level &lt;code&gt;contents: read&lt;/code&gt; means any job you add later starts with the minimum. The job-level block replaces (not merges with) the workflow-level one, which is why &lt;code&gt;contents: read&lt;/code&gt; is repeated there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: declare read-only at the top, grant write per job, and repeat every scope the job still needs.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does it still fail on fork pull requests?
&lt;/h2&gt;

&lt;p&gt;Because the restriction is deliberate. A fork's code is untrusted; if it could run with a write token or your secrets, anyone could open a PR that exfiltrates them. So a &lt;code&gt;pull_request&lt;/code&gt; run from a fork cannot comment, label, or push, whatever your YAML says. In my testing, the confusing part is that the same workflow succeeds on branches inside the repo and fails only for outside contributors.&lt;/p&gt;

&lt;p&gt;Your options, from safest to riskiest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Split the work.&lt;/strong&gt; Let the untrusted &lt;code&gt;pull_request&lt;/code&gt; run build and test, save the results as an artifact, and have a second workflow triggered by &lt;code&gt;workflow_run&lt;/code&gt; (which runs in the base repo's context) download the artifact and post the comment. This keeps untrusted code away from the write token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skip the comment for forks.&lt;/strong&gt; Guard the step with &lt;code&gt;if: github.event.pull_request.head.repo.full_name == github.repository&lt;/code&gt;, so internal PRs get the comment and forks just pass quietly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pull_request_target&lt;/code&gt;.&lt;/strong&gt; It runs with a write token against the base branch, but if you check out and execute the PR's code in that job you have handed the keys to the contributor. Use it only for steps that never run PR-supplied code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same logic applies to Dependabot PRs: they get a read-only token and a separate Dependabot secrets store. If an auto-merge or comment workflow fails only on Dependabot branches, that is why.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: for fork PRs, do not fight the read-only token; move the privileged step into a trusted &lt;code&gt;workflow_run&lt;/code&gt; job.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What about reusable workflows and organization policy?
&lt;/h2&gt;

&lt;p&gt;Two more causes that look identical in the log:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reusable workflows can only reduce permissions.&lt;/strong&gt; A called workflow inherits the caller's token and can downgrade it, but it cannot grant itself something the caller did not allow. Fix the &lt;code&gt;permissions:&lt;/code&gt; on the &lt;em&gt;calling&lt;/em&gt; job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Org-level restrictions.&lt;/strong&gt; An organization owner can cap the default token to read-only and prevent repositories from overriding it. If your &lt;code&gt;permissions:&lt;/code&gt; block looks correct and the call still returns 403, check the org's Actions settings before you debug the YAML.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When a step needs more than the token can ever give, such as pushing to a different repository or triggering a workflow from a workflow (events created with &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; generally do not start new runs), use a GitHub App installation token or a fine-grained personal access token stored as a secret. A GitHub App is the better long-term choice because it is not tied to one person's account. The honest drawback: it adds setup (create the app, install it, mint a token in the job), so use it only when &lt;code&gt;permissions:&lt;/code&gt; genuinely cannot cover the case.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: if the YAML is right and it still 403s, the ceiling is set by the calling workflow or the organization, not by your job.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why does GitHub Actions say "Resource not accessible by integration"?
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; for that run does not have the permission scope the API endpoint requires. Add the matching scope, such as &lt;code&gt;pull-requests: write&lt;/code&gt;, to the job's &lt;code&gt;permissions:&lt;/code&gt; block.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is it safe to set workflow permissions to "read and write" for all workflows?
&lt;/h3&gt;

&lt;p&gt;It works, but it gives every job, including ones that run third-party actions, write access it does not need. Keep the repository default read-only and grant write per job.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why can't my workflow comment on pull requests from forks?
&lt;/h3&gt;

&lt;p&gt;Fork PRs run with a read-only &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; and no secrets by design. Post the comment from a separate &lt;code&gt;workflow_run&lt;/code&gt; workflow instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;If you own the repo and the failing job is a trusted internal one, add a job-level &lt;code&gt;permissions:&lt;/code&gt; block with the single scope from the table and stop there. If the failure only happens for forks or Dependabot, restructure so the privileged step runs through &lt;code&gt;workflow_run&lt;/code&gt; rather than loosening the token. If you need cross-repository writes or chained workflow triggers, move to a GitHub App token. Treat "read and write for everything" as a last resort, not a fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/preview-environments-per-pull-request-how-to-seed-them-without-cloning-production-1f08"&gt;Preview Environments Per Pull Request: How to Seed Them Without Cloning Production&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/chatgpt-free-vs-go-vs-plus-vs-pro-which-tier-should-a-coding-beginner-actually-pay-for-jke"&gt;ChatGPT Free vs Go vs Plus vs Pro: Which Tier Should a Coding Beginner Actually Pay For?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/when-should-you-move-off-managed-hosting-to-kubernetes-the-thresholds-that-actually-matter-5bbl"&gt;When Should You Move Off Managed Hosting to Kubernetes? The Thresholds That Actually Matter&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>github</category>
      <category>cicd</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>Vercel vs Netlify vs Cloudflare Pages: Which Meter Will Your Frontend Hosting Bill Actually Grow On?</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Sat, 07 Nov 2026 15:14:07 +0000</pubDate>
      <link>https://dev.to/libme/vercel-vs-netlify-vs-cloudflare-pages-which-meter-will-your-frontend-hosting-bill-actually-grow-on-23jp</link>
      <guid>https://dev.to/libme/vercel-vs-netlify-vs-cloudflare-pages-which-meter-will-your-frontend-hosting-bill-actually-grow-on-23jp</guid>
      <description>&lt;p&gt;The headline plan price rarely decides your frontend hosting bill. Three meters do: outbound bandwidth, server-side compute (functions, SSR, image optimization), and per-seat charges. Cloudflare Pages is the safest choice when bandwidth is the risk, Vercel is the smoothest when you run Next.js with SSR and a team, and Netlify sits in between with a model that has changed enough that you should re-read it before committing.&lt;/p&gt;

&lt;p&gt;All plan details below are as of November 2026. These vendors revise pricing often, so use this post for the structure of the decision and confirm current numbers on each pricing page.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is a frontend hosting bill actually made of?
&lt;/h2&gt;

&lt;p&gt;Almost every overage I have seen traces back to one of four line items:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bandwidth (egress)&lt;/strong&gt;: bytes served from the CDN to browsers. Big images, video, and source maps nobody needed to ship all land here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compute&lt;/strong&gt;: serverless function invocations and duration, SSR rendering, middleware/edge execution, and image optimization requests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build minutes and concurrency&lt;/strong&gt;: how long CI-style builds take and how many can run at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Seats&lt;/strong&gt;: some plans charge per team member who can deploy, even if they only deploy once a month.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A static marketing site is dominated by item 1. A Next.js app with server components and personalised pages is dominated by item 2. A five-person agency with forty small sites is dominated by item 4. Pick the host that is cheapest on &lt;em&gt;your&lt;/em&gt; dominant meter, not the one with the nicest free tier.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Takeaway: identify your dominant meter first; comparing plan sticker prices before that is comparing the wrong number.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do the three differ on each meter?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Meter&lt;/th&gt;
&lt;th&gt;Vercel&lt;/th&gt;
&lt;th&gt;Netlify&lt;/th&gt;
&lt;th&gt;Cloudflare Pages&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bandwidth&lt;/td&gt;
&lt;td&gt;Included allowance per plan, metered overage beyond it&lt;/td&gt;
&lt;td&gt;Included allowance, metered beyond it (pricing model revised in recent years, check current)&lt;/td&gt;
&lt;td&gt;Static asset requests are not metered for bandwidth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server compute&lt;/td&gt;
&lt;td&gt;Metered functions (invocations, duration, memory); strongest Next.js support&lt;/td&gt;
&lt;td&gt;Metered functions and edge functions&lt;/td&gt;
&lt;td&gt;Pages Functions run on Workers and bill by request/CPU time on the Workers plan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Seats&lt;/td&gt;
&lt;td&gt;Paid plans charge per deploying member&lt;/td&gt;
&lt;td&gt;Paid plans have team-member pricing&lt;/td&gt;
&lt;td&gt;Seats are not the lever; account-level plan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fit for Next.js&lt;/td&gt;
&lt;td&gt;Native, first-party&lt;/td&gt;
&lt;td&gt;Supported through an adapter/runtime&lt;/td&gt;
&lt;td&gt;Supported via adapters; some features lag or behave differently&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Main drawback&lt;/td&gt;
&lt;td&gt;Overages and per-seat cost surprise growing teams&lt;/td&gt;
&lt;td&gt;Credit/plan model needs reading carefully; costs hard to predict&lt;/td&gt;
&lt;td&gt;Smaller feature surface for framework-specific SSR behaviour&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern is that Cloudflare monetises compute and add-on products rather than static egress, while Vercel and Netlify meter the delivery itself. That is why a viral image-heavy page can be a non-event on one and a surprise invoice on another.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Takeaway: if your site is mostly static assets and traffic is unpredictable, an unmetered-egress host removes the worst tail risk.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When does SSR or image optimization become the real cost?
&lt;/h2&gt;

&lt;p&gt;This is where I have seen Next.js projects get caught. Rendering a page on every request means every visitor consumes compute, and image optimization means each unique width/format combination can be a billable transformation on hosts that meter it.&lt;/p&gt;

&lt;p&gt;Before you migrate or upgrade, find out which routes are actually dynamic. In a Next.js project, the build output lists 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="c"&gt;# After `next build`, the route table marks each route.&lt;/span&gt;
&lt;span class="c"&gt;# ○ = static, ● = SSG with data, ƒ = dynamic (server-rendered on demand)&lt;/span&gt;
npx next build 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'^\s*(○|●|ƒ)'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every &lt;code&gt;ƒ&lt;/code&gt; route is a compute meter. Often a route is dynamic only because it reads &lt;code&gt;cookies()&lt;/code&gt; or &lt;code&gt;headers()&lt;/code&gt; somewhere in the tree, or because a fetch is uncached. Converting accidental dynamic routes to static or revalidated ones cuts the compute meter on every host, which is a bigger lever than switching providers.&lt;/p&gt;

&lt;p&gt;For images, two honest options exist: keep the host's optimizer and cap the variants (a limited &lt;code&gt;deviceSizes&lt;/code&gt; list in &lt;code&gt;next.config.js&lt;/code&gt;), or pre-generate sizes at build time and serve them as plain static files. The second removes the meter entirely at the cost of a build step you maintain.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Takeaway: audit which routes are dynamic before blaming the host; accidental SSR is the most common avoidable compute cost.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I estimate my bill before I commit?
&lt;/h2&gt;

&lt;p&gt;Do not trust intuition; plug your own numbers into a model with the rates from each pricing page. This small script makes the structure explicit. The rates are placeholders you must fill in yourself, deliberately not real prices.&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;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;

&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Plan&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;base_monthly&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;        &lt;span class="c1"&gt;# plan fee
&lt;/span&gt;    &lt;span class="n"&gt;seats_included&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;per_extra_seat&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;
    &lt;span class="n"&gt;gb_included&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;
    &lt;span class="n"&gt;per_extra_gb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;        &lt;span class="c1"&gt;# 0 if egress is unmetered
&lt;/span&gt;    &lt;span class="n"&gt;fn_million_included&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;
    &lt;span class="n"&gt;per_extra_million_fn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;monthly_cost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Plan&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seats&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gb_out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fn_millions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;extra_seats&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seats&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seats_included&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;extra_gb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gb_out&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gb_included&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;extra_fn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fn_millions&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fn_million_included&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;base_monthly&lt;/span&gt;
            &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;extra_seats&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;per_extra_seat&lt;/span&gt;
            &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;extra_gb&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;per_extra_gb&lt;/span&gt;
            &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;extra_fn&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;per_extra_million_fn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Fill from each vendor's current pricing page. These zeros are placeholders.
&lt;/span&gt;&lt;span class="n"&gt;plans&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;Plan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;host_a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nc"&gt;Plan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;host_b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# Run three scenarios: today, 3x traffic, and a viral spike month.
&lt;/span&gt;&lt;span class="n"&gt;scenarios&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;today&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3x&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spike&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;span class="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;plans&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;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seats&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;scenarios&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;monthly_cost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seats&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scenario that matters is the spike month, not today. A host with a slightly higher base fee and unmetered egress often wins the spike scenario by a wide margin, while a host with generous bundled compute wins the dynamic-app scenario.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Takeaway: model today, 3x, and a spike month; the host that wins only "today" is the one that surprises you later.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Is it worth paying for the platform that matches my framework?
&lt;/h2&gt;

&lt;p&gt;Sometimes. If you run Next.js with server components, ISR and middleware, the first-party host removes a class of "works locally, behaves differently deployed" problems, and that time has real value. If you ship a static Astro, Vite, or Hugo site, you are paying a premium for features you do not use, and an unmetered-egress host is the rational default.&lt;/p&gt;

&lt;p&gt;Each option has a real downside. Vercel's per-seat pricing punishes teams where many people occasionally deploy, and its overages can arrive without a hard stop unless you configure spend controls. Netlify's pricing structure has been revised enough that older blog posts (including comparisons from a couple of years ago) are unreliable. Cloudflare Pages gives you the least friction on cost but, for framework-specific SSR features, you may hit behavioural differences from Node-based hosting because the runtime is not Node by default.&lt;/p&gt;

&lt;p&gt;If you want the managed Next.js experience and accept the metering, Vercel is the host that handles SSR, ISR and image optimization without extra adapter work. If your risk is egress on a mostly static site, Cloudflare Pages is the one that does not bill you for serving static assets.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Takeaway: pay for framework fit only if you use the framework features that need it.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is Cloudflare Pages really free for unlimited bandwidth?
&lt;/h3&gt;

&lt;p&gt;Static asset requests on Pages are not metered for bandwidth as of November 2026, but Pages Functions run on Workers and are subject to Workers request and CPU limits and pricing. Check the current limits for your plan.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is my Vercel bill higher than the plan price?
&lt;/h3&gt;

&lt;p&gt;Usually usage beyond the included allowances (bandwidth, function duration, image optimization) or additional paid seats. Open the usage dashboard and sort by line item to see which one grew.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I move a Next.js app off Vercel to avoid costs?
&lt;/h3&gt;

&lt;p&gt;Yes, but test every dynamic feature (middleware, ISR, image optimization) on the target host first, since adapters and runtimes do not always match Vercel's behaviour exactly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;Static or image-heavy sites with unpredictable traffic fit Cloudflare Pages, because egress is the risk and it is not metered there. Next.js teams that use SSR, ISR and middleware heavily will usually spend less engineering time on Vercel, as long as they set spend limits and count seats honestly. Netlify is worth a fresh look if you value its build and form tooling, but read its current pricing model rather than relying on older comparisons. Whatever you choose, audit your dynamic routes first; that fixes more of the bill than any migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/stop-guessing-your-webhook-dedup-ttl-derive-it-from-the-delivery-contract-1978"&gt;Stop Guessing Your Webhook Dedup TTL: Derive It From the Delivery Contract&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/postgres-as-a-job-queue-vs-redis-vs-sqs-when-does-just-use-your-database-stop-working-1f24"&gt;Postgres as a Job Queue vs Redis vs SQS: When Does "Just Use Your Database" Stop Working?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/github-push-rejected-file-exceeds-100-mb-gh001-fix-it-with-filter-repo-or-lfs-f31"&gt;GitHub Push Rejected: "File Exceeds 100 MB" (GH001) — Fix It With filter-repo or LFS&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>cloud</category>
      <category>nextjs</category>
      <category>saas</category>
    </item>
    <item>
      <title>LLM Returns Invalid JSON Once in a While: Structured Outputs vs Validate-and-Retry</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Fri, 06 Nov 2026 22:31:25 +0000</pubDate>
      <link>https://dev.to/libme/llm-returns-invalid-json-once-in-a-while-structured-outputs-vs-validate-and-retry-5faj</link>
      <guid>https://dev.to/libme/llm-returns-invalid-json-once-in-a-while-structured-outputs-vs-validate-and-retry-5faj</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; If &lt;code&gt;json.loads()&lt;/code&gt; on model output fails only occasionally, there are three distinct causes: truncation, stray prose or code fences around the JSON, and schema drift (valid JSON, wrong shape). Provider-side structured outputs (constrained decoding) eliminate the second and third for supported schemas; a validate-and-retry loop with a typed model covers everything else, including the first. In production you usually want both.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the failure actually look like?
&lt;/h2&gt;

&lt;p&gt;The symptom that sends people to a search engine is usually one of these:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)
json.decoder.JSONDecodeError: Unterminated string starting at: line 1 column 140 (char 139)
pydantic_core._pydantic_core.ValidationError: 1 validation error for Ticket
priority
  Input should be less than or equal to 4 [type=less_than_equal, ...]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They look alike but have different fixes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Real cause&lt;/th&gt;
&lt;th&gt;Right fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Expecting value: line 1 column 1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Model wrapped the JSON in a code fence or added "Here is the JSON:"&lt;/td&gt;
&lt;td&gt;Constrained decoding, or extract the outermost braces&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Unterminated string&lt;/code&gt; / &lt;code&gt;Expecting ',' delimiter&lt;/code&gt; near the end&lt;/td&gt;
&lt;td&gt;Output hit &lt;code&gt;max_tokens&lt;/code&gt; mid-object (truncation)&lt;/td&gt;
&lt;td&gt;Raise the limit, shrink the schema, check the stop reason&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parses fine, then your code crashes on a missing key or odd enum value&lt;/td&gt;
&lt;td&gt;Schema drift: valid JSON, wrong shape&lt;/td&gt;
&lt;td&gt;Typed validation, or a strict schema on the provider side&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first thing to do before changing any prompt: log the raw response and the stop/finish reason for every failure. Most "the model is flaky" reports I have seen turned out to be truncation, which no prompt wording can fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: classify the failure from the raw text and the stop reason before you touch the prompt.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Validate with a typed model instead of &lt;code&gt;json.loads&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Parsing and validating in one step gives you an error message precise enough to feed back to the model. This uses Pydantic v2 and works with any provider, because &lt;code&gt;call_model&lt;/code&gt; is just a function you supply.&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;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Callable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&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;Field&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Ticket&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;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bug&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;billing&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;feature_request&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ge&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;min_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;200&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;extract_json_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&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;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Strip code fences / chatter by taking the outermost braces.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rfind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;start&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;no JSON object found in response&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;raw&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;end&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;parse_ticket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&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="n"&gt;Ticket&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;Ticket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_validate_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;extract_json_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The brace extraction is a blunt tool: it will not rescue truncated output, and it can mis-slice if the model emits two objects. That is fine, because the retry loop below catches those cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Retry with the error, not with the same prompt
&lt;/h2&gt;

&lt;p&gt;Resending the identical request is a coin flip. Sending the validation error back gives the model something concrete to correct. In Pydantic v2, &lt;code&gt;ValidationError&lt;/code&gt; subclasses &lt;code&gt;ValueError&lt;/code&gt;, so one &lt;code&gt;except&lt;/code&gt; covers both parse and schema failures.&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;Message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;dict&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="nb"&gt;str&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;extract_ticket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;call_model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Callable&lt;/span&gt;&lt;span class="p"&gt;[[&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Message&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;prompt&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;max_attempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&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="n"&gt;Ticket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Message&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&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;user&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;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;
    &lt;span class="n"&gt;last_error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;call_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;parse_ticket&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt; &lt;span class="k"&gt;as&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;last_error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;
            &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&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;assistant&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;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&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;user&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;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;That response failed validation:&lt;/span&gt;&lt;span class="se"&gt;\n&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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Reply with only a corrected JSON object.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                    &lt;span class="p"&gt;),&lt;/span&gt;
                &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&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;no valid output after &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; attempts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;last_error&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that matter. Cap attempts at 2–3: each retry costs a full round trip and re-sends the growing conversation, so an uncapped loop turns one bad input into a bill spike. And if the failure was truncation, retrying with the same &lt;code&gt;max_tokens&lt;/code&gt; will truncate again, so check the stop reason and raise the limit instead of looping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: a retry that includes the exact validation error converges; a retry that repeats the prompt just re-rolls the dice.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When do provider-side structured outputs replace the loop?
&lt;/h2&gt;

&lt;p&gt;Most major providers now offer some form of schema-constrained output: OpenAI's Structured Outputs with a strict JSON schema, tool/function calling with an input schema on Anthropic's API, and grammar- or schema-constrained decoding in local runtimes such as llama.cpp and in libraries like Outlines. The model's token choices are restricted so the output cannot violate the schema's syntax. As of late 2026 the details differ per provider, so check current docs for supported schema features.&lt;/p&gt;

&lt;p&gt;What this buys you: no code fences, no prose preamble, no invalid enum values, no missing required keys. If you want this without writing the plumbing yourself, OpenAI's Structured Outputs is the option that guarantees schema adherence on a strict schema, at the cost of being tied to that API.&lt;/p&gt;

&lt;p&gt;What it does not buy you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Truncation is still possible.&lt;/strong&gt; If generation stops at the token limit, the object is still cut off. Strict mode does not make that safe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only a schema subset is supported.&lt;/strong&gt; Constraints like &lt;code&gt;minLength&lt;/code&gt;, &lt;code&gt;maximum&lt;/code&gt;, or deeply recursive structures are often unsupported or ignored, so your &lt;code&gt;Field(ge=1, le=4)&lt;/code&gt; check may still need to run in your own code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Syntax is not semantics.&lt;/strong&gt; A schema-valid &lt;code&gt;priority: 4&lt;/code&gt; can still be the wrong priority. Constrained decoding makes the shape certain, not the content correct.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First-request latency.&lt;/strong&gt; Some providers compile the schema on first use and cache it, so a rarely used schema can be slower on its first call.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: constrained decoding guarantees shape, not truncation-safety and not correctness, so keep your own validation behind it.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Which approach should you use?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hosted model, simple flat schema&lt;/td&gt;
&lt;td&gt;Provider structured outputs plus Pydantic validation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider lacks strict mode, or schema uses unsupported constraints&lt;/td&gt;
&lt;td&gt;Validate-and-retry loop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local model (llama.cpp, Ollama-style runtimes)&lt;/td&gt;
&lt;td&gt;Grammar/schema-constrained decoding if your runtime supports it, retry loop as backstop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Long outputs (big arrays, many records)&lt;/td&gt;
&lt;td&gt;Split into smaller calls; truncation risk grows with output length&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why does the LLM return invalid JSON only sometimes?&lt;/strong&gt;&lt;br&gt;
Sampling is probabilistic, so a model that follows your format 98% of the time will still occasionally add a code fence, stop mid-object at the token limit, or pick an off-schema value. Intermittent failures are normal without constrained decoding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does asking for JSON in the prompt guarantee valid JSON?&lt;/strong&gt;&lt;br&gt;
No. A prompt instruction raises the odds but guarantees nothing; only schema-constrained decoding restricts the output tokens, and even then truncation can break the object.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How many retries should I allow for LLM JSON parsing?&lt;/strong&gt;&lt;br&gt;
Two or three. If the third attempt still fails, the cause is usually truncation or an ambiguous schema, and more retries will not help.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;If you call a hosted model with a simple schema, turn on the provider's strict structured output and keep a Pydantic model behind it for the constraints the schema cannot express. If you are on a provider or local model without it, the validate-and-retry loop above is enough for most small-team workloads. Either way, log the raw response and stop reason so you can tell truncation from drift. Cap retries, because the loop's failure mode is cost, not correctness.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/github-actions-vs-circleci-vs-buildkite-the-real-cost-of-free-ci-minutes-4p57"&gt;GitHub Actions vs CircleCI vs Buildkite: The Real Cost of "Free" CI Minutes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/npm-ci-says-packagejson-and-package-lockjson-are-not-in-sync-causes-fixes-and-how-pnpm-and-161o"&gt;npm ci Says package.json and package-lock.json Are Not in Sync: Causes, Fixes, and How pnpm and Yarn Compare&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/zapier-vs-make-vs-n8n-when-paying-per-task-stops-making-sense-327b"&gt;Zapier vs Make vs n8n: When Paying Per Task Stops Making Sense&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>python</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Docker Build Secrets: Why ARG and COPY Leak Tokens Into Image History, and the BuildKit Fix</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Thu, 05 Nov 2026 18:32:32 +0000</pubDate>
      <link>https://dev.to/libme/docker-build-secrets-why-arg-and-copy-leak-tokens-into-image-history-and-the-buildkit-fix-obg</link>
      <guid>https://dev.to/libme/docker-build-secrets-why-arg-and-copy-leak-tokens-into-image-history-and-the-buildkit-fix-obg</guid>
      <description>&lt;p&gt;If you pass an npm token, pip index password, or SSH key into &lt;code&gt;docker build&lt;/code&gt; with &lt;code&gt;ARG&lt;/code&gt;, &lt;code&gt;ENV&lt;/code&gt;, or &lt;code&gt;COPY&lt;/code&gt;, it can be recovered from the image afterward, even if a later step deletes it. The fix is a BuildKit secret mount (&lt;code&gt;RUN --mount=type=secret&lt;/code&gt;), which exposes the secret to one &lt;code&gt;RUN&lt;/code&gt; step as a temporary file and never writes it into a layer. Below: how to prove the leak on your own image, the replacement pattern, and the traps that remain.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I check whether my image already leaks a secret?
&lt;/h2&gt;

&lt;p&gt;Start with the evidence, not the theory. Take a Dockerfile that looks reasonable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; node:22-slim&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;ARG&lt;/span&gt;&lt;span class="s"&gt; NPM_TOKEN&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package*.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"//registry.npmjs.org/:_authToken=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;NPM_TOKEN&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; .npmrc &lt;span class="se"&gt;\
&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm ci &lt;span class="se"&gt;\
&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; .npmrc
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The author deleted &lt;code&gt;.npmrc&lt;/code&gt; in the same step, so it feels safe. Build it and inspect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="nt"&gt;--build-arg&lt;/span&gt; &lt;span class="nv"&gt;NPM_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;npm_fake_example_token &lt;span class="nt"&gt;-t&lt;/span&gt; leaky &lt;span class="nb"&gt;.&lt;/span&gt;
docker &lt;span class="nb"&gt;history&lt;/span&gt; &lt;span class="nt"&gt;--no-trunc&lt;/span&gt; leaky | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; npm_token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;ARG&lt;/code&gt; value appears in the recorded command of that layer in the history output, because the expanded &lt;code&gt;RUN&lt;/code&gt; instruction is part of the image metadata. Anyone who can pull the image can run &lt;code&gt;docker history&lt;/code&gt; or &lt;code&gt;docker inspect&lt;/code&gt; on it.&lt;/p&gt;

&lt;p&gt;Recent Docker versions also warn at build time with a check named &lt;code&gt;SecretsUsedInArgOrEnv&lt;/code&gt; ("Do not use ARG or ENV instructions for sensitive data"). It is name-based, so it catches &lt;code&gt;NPM_TOKEN&lt;/code&gt; or &lt;code&gt;PASSWORD&lt;/code&gt; but will not catch a secret in a variable called &lt;code&gt;X&lt;/code&gt;. Treat it as a smoke alarm, not an audit.&lt;/p&gt;

&lt;p&gt;The same applies to &lt;code&gt;COPY .npmrc .&lt;/code&gt; followed by &lt;code&gt;RUN rm .npmrc&lt;/code&gt;: the &lt;code&gt;COPY&lt;/code&gt; layer is a tarball of the file, and deleting it in a later layer only hides it from the merged filesystem. Extract the layers with &lt;code&gt;docker save&lt;/code&gt; and the file is there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: deleting a file in a later layer does not remove it from the image; it only masks it.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the BuildKit secret mount do differently?
&lt;/h2&gt;

&lt;p&gt;A secret mount makes a file (or env value) available only while one &lt;code&gt;RUN&lt;/code&gt; instruction executes. It is mounted on a tmpfs-style path, not committed into the layer, and not recorded as a build argument.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# syntax=docker/dockerfile:1&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; node:22-slim&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package*.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nt"&gt;--mount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;secret,id&lt;span class="o"&gt;=&lt;/span&gt;npmrc,target&lt;span class="o"&gt;=&lt;/span&gt;/root/.npmrc &lt;span class="se"&gt;\
&lt;/span&gt;    npm ci
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Build by pointing the &lt;code&gt;id&lt;/code&gt; at a source on the host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="nt"&gt;--secret&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;npmrc,src&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;/.npmrc &lt;span class="nt"&gt;-t&lt;/span&gt; safe &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the token is in an environment variable instead of a file, BuildKit can read it from the environment of the build command:&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;export &lt;/span&gt;&lt;span class="nv"&gt;NPM_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;npm_fake_example_token
docker build &lt;span class="nt"&gt;--secret&lt;/span&gt; &lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;NPM_TOKEN,env&lt;span class="o"&gt;=&lt;/span&gt;NPM_TOKEN &lt;span class="nt"&gt;-t&lt;/span&gt; safe &lt;span class="nb"&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 docker"&gt;&lt;code&gt;&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nt"&gt;--mount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;secret,id&lt;span class="o"&gt;=&lt;/span&gt;NPM_TOKEN &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"//registry.npmjs.org/:_authToken=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /run/secrets/NPM_TOKEN&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/.npmrc &lt;span class="se"&gt;\
&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;NPM_CONFIG_USERCONFIG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/tmp/.npmrc npm ci &lt;span class="se"&gt;\
&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; /tmp/.npmrc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By default a secret mounted without &lt;code&gt;target&lt;/code&gt; appears at &lt;code&gt;/run/secrets/&amp;lt;id&amp;gt;&lt;/code&gt;. Note the second form writes a temp file inside the layer filesystem, but it is removed within the same &lt;code&gt;RUN&lt;/code&gt;, and the token was never in the instruction text, so neither &lt;code&gt;docker history&lt;/code&gt; nor the committed layer contains it. If you can use the first form with a real file at &lt;code&gt;target&lt;/code&gt;, prefer it: there is nothing to clean up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: a secret mount keeps the value out of both the instruction text and the layer contents, which is the two places &lt;code&gt;ARG&lt;/code&gt; and &lt;code&gt;COPY&lt;/code&gt; leak.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Which method leaks what?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;In &lt;code&gt;docker history&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;In a layer tarball&lt;/th&gt;
&lt;th&gt;Survives to final image&lt;/th&gt;
&lt;th&gt;Use for secrets?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ARG&lt;/code&gt; + &lt;code&gt;--build-arg&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Yes, when used in &lt;code&gt;RUN&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Possibly, if written to a file&lt;/td&gt;
&lt;td&gt;Metadata yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ENV&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;td&gt;Yes, and in running containers&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;COPY&lt;/code&gt; then &lt;code&gt;rm&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Masked, recoverable&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-stage, secret only in builder stage&lt;/td&gt;
&lt;td&gt;In builder only&lt;/td&gt;
&lt;td&gt;In builder only&lt;/td&gt;
&lt;td&gt;No, if final stage does not copy it&lt;/td&gt;
&lt;td&gt;Acceptable, with caveats&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RUN --mount=type=secret&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RUN --mount=type=ssh&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes, for git over SSH&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The multi-stage row deserves the caveat: the final image is clean, but the builder stage's layers still exist in your local build cache, and if you export cache with &lt;code&gt;--cache-to&lt;/code&gt; to a registry, those layers can go with it. Which is why "multi-stage" alone is not a secrets strategy.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about private git dependencies and CI?
&lt;/h2&gt;

&lt;p&gt;For &lt;code&gt;git clone&lt;/code&gt; or &lt;code&gt;pip install git+ssh://...&lt;/code&gt; from a private repo, use the SSH mount rather than copying a key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# syntax=docker/dockerfile:1&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nt"&gt;--mount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ssh &lt;span class="se"&gt;\
&lt;/span&gt;    pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"git+ssh://git@github.com/your-org/private-lib.git"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker build &lt;span class="nt"&gt;--ssh&lt;/span&gt; default &lt;span class="nt"&gt;-t&lt;/span&gt; app &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This forwards your local SSH agent for that step only, so no private key lands in a layer. You will also need &lt;code&gt;github.com&lt;/code&gt; in &lt;code&gt;known_hosts&lt;/code&gt; inside the build, which is the usual first failure: &lt;code&gt;Host key verification failed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In GitHub Actions with &lt;code&gt;docker/build-push-action&lt;/code&gt;, the &lt;code&gt;secrets&lt;/code&gt; input takes the same &lt;code&gt;id=value&lt;/code&gt; pairs:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/build-push-action@v6&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ghcr.io/your-org/app:latest&lt;/span&gt;
    &lt;span class="na"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
      &lt;span class="s"&gt;npmrc=${{ secrets.NPMRC }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As of November 2026 this works with BuildKit-based builds, which is the default builder in current Docker releases. Pin the action to the major version you have tested.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: the pattern is identical locally and in CI; only the source of the secret changes.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What still goes wrong after you switch?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A secret echoed into build output.&lt;/strong&gt; If a step fails verbosely (&lt;code&gt;npm ci&lt;/code&gt; with debug logging, &lt;code&gt;pip -vvv&lt;/code&gt;), the token can appear in CI logs. Mask it in your CI system and keep verbosity off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets written to a path the tool caches.&lt;/strong&gt; Some package managers copy credentials into a cache directory inside the layer. Check with &lt;code&gt;docker run --rm safe sh -c 'find / -name ".npmrc" 2&amp;gt;/dev/null'&lt;/code&gt; after the build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Old images are already published.&lt;/strong&gt; Switching Dockerfiles does not un-leak what is in your registry. If an image with a real token was ever pushed, rotate the token first; deleting the tag is secondary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inspecting tools have limits.&lt;/strong&gt; &lt;code&gt;dive&lt;/code&gt; is useful for browsing layer contents interactively, but it is a manual check and will not scan for credentials. A dedicated secret scanner run against the saved image tarball is a better gate in CI.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want automated detection rather than eyeballing layers, TruffleHog is the open-source scanner that can check container images for verified credentials; its limitation is that it finds what looks like a known credential format, so an internal password with no recognizable pattern slips through.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does deleting a file in a later Dockerfile step remove it from the image?&lt;/strong&gt;&lt;br&gt;
No. Each instruction creates a new layer, and a delete only adds a "whiteout" marker in the new layer. The earlier layer still contains the file and can be extracted from the image.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is multi-stage build enough to keep secrets out of the final image?&lt;/strong&gt;&lt;br&gt;
It keeps them out of the final image's layers if the final stage never copies them, but the builder layers remain in your build cache and possibly in exported cache. Use a secret mount so the value never enters a layer at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I use a secret in &lt;code&gt;docker build&lt;/code&gt; without BuildKit?&lt;/strong&gt;&lt;br&gt;
You can't do it safely with the legacy builder. Enable BuildKit (the default in current Docker Engine and Docker Desktop) and use &lt;code&gt;--secret&lt;/code&gt; with &lt;code&gt;RUN --mount=type=secret&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;If a credential is needed only to install dependencies during the build, use &lt;code&gt;RUN --mount=type=secret&lt;/code&gt; for it, and &lt;code&gt;--mount=type=ssh&lt;/code&gt; for private git over SSH. Stop using &lt;code&gt;ARG&lt;/code&gt; and &lt;code&gt;ENV&lt;/code&gt; for anything you would not paste into a public chat. If an image with a real secret was ever pushed, rotate the secret before you fix the Dockerfile.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/why-your-postgres-migration-locked-the-whole-table-and-the-pattern-that-doesnt-38k4"&gt;Why Your Postgres Migration Locked the Whole Table (and the Pattern That Doesn't)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/uuidv7-vs-ulid-vs-bigint-which-primary-key-holds-up-when-the-table-gets-big-4mkg"&gt;UUIDv7 vs ULID vs bigint: Which Primary Key Holds Up When the Table Gets Big?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/is-paying-for-bigger-ci-runners-worth-it-break-even-math-against-caching-and-test-sharding-1da0"&gt;Is Paying for Bigger CI Runners Worth It? Break-Even Math Against Caching and Test Sharding&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>docker</category>
      <category>security</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>ENOSPC: System Limit for Number of File Watchers Reached — Raise the Limit, Shrink the Watch, or Poll?</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Wed, 04 Nov 2026 15:13:09 +0000</pubDate>
      <link>https://dev.to/libme/enospc-system-limit-for-number-of-file-watchers-reached-raise-the-limit-shrink-the-watch-or-33c5</link>
      <guid>https://dev.to/libme/enospc-system-limit-for-number-of-file-watchers-reached-raise-the-limit-shrink-the-watch-or-33c5</guid>
      <description>&lt;p&gt;If your dev server dies with &lt;code&gt;Error: ENOSPC: System limit for number of file watchers reached&lt;/code&gt;, it has nothing to do with disk space. You've hit the Linux kernel's inotify watch limit. You have three fixes: raise &lt;code&gt;fs.inotify.max_user_watches&lt;/code&gt; on the host, make your tools watch fewer directories, or switch to polling when inotify can't work at all (some Docker bind mounts, network drives).&lt;/p&gt;

&lt;p&gt;The right choice depends on which of those situations you're in, and the order matters. Raising the limit is the fastest fix, but it hides the real problem if a tool is watching &lt;code&gt;node_modules&lt;/code&gt; for no reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does ENOSPC mean when there's plenty of disk space?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;ENOSPC&lt;/code&gt; is "no space left on device", but the kernel reuses it for inotify. The message usually looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: ENOSPC: System limit for number of file watchers reached, watch '/home/dev/app/src/components'
    at FSWatcher.&amp;lt;computed&amp;gt; (node:internal/fs/watchers:247:19)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two different limits produce it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Limit&lt;/th&gt;
&lt;th&gt;What it caps&lt;/th&gt;
&lt;th&gt;Typical symptom&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fs.inotify.max_user_watches&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Total watches per user (roughly one per watched directory)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;...System limit for number of file watchers reached, watch '&amp;lt;path&amp;gt;'&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fs.inotify.max_user_instances&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Number of inotify handles per user&lt;/td&gt;
&lt;td&gt;Same ENOSPC, but no path, or &lt;code&gt;inotify_init&lt;/code&gt; failing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Linux inotify watches directories, not individual files, so the cost scales with directory count. A monorepo with several thousand folders, plus a bundler, a test runner, a TypeScript server and an editor all watching the same tree, can use up the limit quickly. As of kernel 5.11 the default &lt;code&gt;max_user_watches&lt;/code&gt; scales with system RAM (up to about a million); older kernels and many distro configs default to 8192, which is where most people hit it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: ENOSPC from a &lt;code&gt;watch&lt;/code&gt; call is an inotify quota error, and the quota is per user, shared by every tool you have running.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I see who is using all the watches?
&lt;/h2&gt;

&lt;p&gt;Before you change anything, check the current limits and find the biggest consumer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/sys/fs/inotify/max_user_watches
&lt;span class="nb"&gt;cat&lt;/span&gt; /proc/sys/fs/inotify/max_user_instances

&lt;span class="c"&gt;# Watches per process (run with sudo to see other users' processes)&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;fd &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;find /proc/&lt;span class="k"&gt;*&lt;/span&gt;/fd &lt;span class="nt"&gt;-lname&lt;/span&gt; &lt;span class="s1"&gt;'anon_inode:inotify'&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;pid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$fd&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;/ &lt;span class="nt"&gt;-f3&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'^inotify'&lt;/span&gt; &lt;span class="s2"&gt;"/proc/&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt;/fdinfo/&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$fd&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'\0'&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &amp;lt; &lt;span class="s2"&gt;"/proc/&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt;/cmdline"&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-c1-80&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In my experience the top entry is rarely the thing you expected. An editor's file watcher, a second dev server you forgot about, or a Jest watch mode running with the whole repo as its root are common culprits. If one process owns most of the watches and it's watching &lt;code&gt;node_modules&lt;/code&gt; or &lt;code&gt;.git&lt;/code&gt;, fix that first (next sections) rather than raising the limit.&lt;/p&gt;

&lt;p&gt;To estimate what a single tool needs, count directories it would actually watch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-type&lt;/span&gt; d &lt;span class="nt"&gt;-not&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; &lt;span class="s1"&gt;'*/node_modules/*'&lt;/span&gt; &lt;span class="nt"&gt;-not&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; &lt;span class="s1"&gt;'*/.git/*'&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Takeaway: one process typically owns most of the watches, and finding it takes thirty seconds.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I raise the inotify limit permanently?
&lt;/h2&gt;

&lt;p&gt;This is the standard fix on a Linux dev machine or CI runner. A one-off change:&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;sudo &lt;/span&gt;sysctl fs.inotify.max_user_watches&lt;span class="o"&gt;=&lt;/span&gt;524288
&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl fs.inotify.max_user_instances&lt;span class="o"&gt;=&lt;/span&gt;512
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To persist across reboots, put it in a drop-in file:&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;printf&lt;/span&gt; &lt;span class="s1"&gt;'fs.inotify.max_user_watches=524288\nfs.inotify.max_user_instances=512\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/sysctl.d/99-inotify.conf
&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;--system&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The numbers above are common community values, not magic. Each watch costs kernel memory (on the order of a kilobyte or two on 64-bit systems), and that memory is unswappable, so setting the limit to the maximum on a small VM is not free. Size it to your tree plus headroom.&lt;/p&gt;

&lt;p&gt;Where you apply it matters. The limit lives in the kernel, so:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On a Linux host running containers, set it on the host. Containers share the host kernel.&lt;/li&gt;
&lt;li&gt;On Docker Desktop for Mac or Windows, the "host" is the Linux VM Docker runs, not your laptop OS.&lt;/li&gt;
&lt;li&gt;In WSL2, set it inside the WSL distribution.&lt;/li&gt;
&lt;li&gt;In managed CI, you often can't change it, which pushes you toward the next two options.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: raise &lt;code&gt;max_user_watches&lt;/code&gt; on the machine whose kernel is actually doing the watching, and write it to &lt;code&gt;/etc/sysctl.d/&lt;/code&gt; so it survives a reboot.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I make my tools watch fewer directories?
&lt;/h2&gt;

&lt;p&gt;Raising the limit treats the symptom. Excluding directories that never change at dev time treats the cause. Settings for the common tools (as of late 2026, check your version's docs):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Vite&lt;/td&gt;
&lt;td&gt;&lt;code&gt;server.watch.ignored&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;webpack / webpack-dev-server&lt;/td&gt;
&lt;td&gt;&lt;code&gt;watchOptions.ignored&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next.js&lt;/td&gt;
&lt;td&gt;Uses webpack/Turbopack watchers; keep generated and output directories out of the project root where possible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jest&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;watchPathIgnorePatterns&lt;/code&gt;, or &lt;code&gt;--no-watchman&lt;/code&gt; to avoid the Watchman crawler&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VS Code&lt;/td&gt;
&lt;td&gt;&lt;code&gt;files.watcherExclude&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tsc &lt;code&gt;--watch&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;watchOptions.excludeDirectories&lt;/code&gt; in &lt;code&gt;tsconfig.json&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For example, a Vite config that stops watching build output and a vendored directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// vite.config.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;ignored&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="s1"&gt;**/dist/**&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;**/.turbo/**&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;**/vendor/**&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="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;And for the TypeScript watcher:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"watchOptions"&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;"excludeDirectories"&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;"**/node_modules"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"**/dist"&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;A caveat from experience: ignoring a directory means edits there will not trigger a rebuild. If you symlink a local package into &lt;code&gt;node_modules&lt;/code&gt; for development, excluding &lt;code&gt;node_modules&lt;/code&gt; will make that package's changes invisible, so check before you ignore.&lt;/p&gt;

&lt;p&gt;If you run several tools that each crawl the repo, Watchman is the one that gives you a single shared watcher service instead of one crawler per tool, which Jest and Metro can use. Its drawback is that on Linux it still uses inotify underneath, so it reduces duplication but does not remove the need for a sane limit, and it adds a daemon to maintain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: if a tool is watching &lt;code&gt;node_modules&lt;/code&gt; or build output, exclude them in its config before you touch the kernel setting.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When should I switch to polling instead?
&lt;/h2&gt;

&lt;p&gt;Polling stat-checks files on a timer, so it uses no inotify watches at all. It trades CPU and latency for reliability. Use it when inotify genuinely can't see changes, not as a reflex:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bind mounts from macOS or Windows into a Linux container often don't deliver filesystem events reliably, so hot reload silently never fires.&lt;/li&gt;
&lt;li&gt;Network filesystems (NFS, SMB) generally don't produce inotify events for changes made on other machines.&lt;/li&gt;
&lt;li&gt;Locked-down CI where you can't change sysctl values.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Common switches:&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;# chokidar-based tools (older webpack setups, nodemon alternatives)&lt;/span&gt;
&lt;span class="nv"&gt;CHOKIDAR_USEPOLLING&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true &lt;/span&gt;npm run dev

&lt;span class="c"&gt;# Next.js (webpack watcher)&lt;/span&gt;
&lt;span class="nv"&gt;WATCHPACK_POLLING&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true &lt;/span&gt;npm run dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// vite.config.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;server&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;watch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;usePolling&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Polling a large tree at a short interval will visibly burn CPU. Pair it with the ignore lists above so you only poll source directories, and keep the interval as long as you can tolerate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: polling is the fallback for places where inotify events never arrive, and it needs an ignore list to stay cheap.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How do I fix "ENOSPC: System limit for number of file watchers reached"?&lt;/strong&gt;&lt;br&gt;
Run &lt;code&gt;sudo sysctl fs.inotify.max_user_watches=524288&lt;/code&gt; and persist it in &lt;code&gt;/etc/sysctl.d/99-inotify.conf&lt;/code&gt;. If a tool is watching &lt;code&gt;node_modules&lt;/code&gt; or build output, exclude those paths too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does ENOSPC in Node mean my disk is full?&lt;/strong&gt;&lt;br&gt;
Not when the message mentions "file watchers" or a &lt;code&gt;watch&lt;/code&gt; call. That is the inotify watch or instance limit. A genuinely full disk shows up in write operations, and &lt;code&gt;df -h&lt;/code&gt; will confirm it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need to set the inotify limit inside the Docker container?&lt;/strong&gt;&lt;br&gt;
No, the container shares the host kernel, so set it on the Linux host or in the Docker Desktop VM. Containers typically can't change it with their own sysctl settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;On a Linux machine you control, raise &lt;code&gt;fs.inotify.max_user_watches&lt;/code&gt; via &lt;code&gt;/etc/sysctl.d/&lt;/code&gt;, but first spend a minute finding which process holds the watches and excluding &lt;code&gt;node_modules&lt;/code&gt; and build output from it. If you're on a bind mount or network drive where events never arrive, use polling with a tight ignore list. If you can't change kernel settings, shrinking the watched set is your only real lever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/where-should-a-small-team-store-its-secrets-comparing-env-files-sops-doppler-1password-and-e13"&gt;Where Should a Small Team Store Its Secrets? Comparing .env Files, SOPS, Doppler, 1Password, and Vault&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/vercel-vs-netlify-vs-cloudflare-pages-where-your-side-project-should-actually-live-1j30"&gt;Vercel vs Netlify vs Cloudflare Pages: Where Your Side Project Should Actually Live&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/when-is-github-copilot-actually-worth-the-subscription-a-break-even-analysis-5c7p"&gt;When Is GitHub Copilot Actually Worth the Subscription? A Break-Even Analysis&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>node</category>
      <category>devops</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Docker Desktop Alternatives on Mac: OrbStack vs Colima vs Podman Desktop</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Tue, 03 Nov 2026 22:15:35 +0000</pubDate>
      <link>https://dev.to/libme/docker-desktop-alternatives-on-mac-orbstack-vs-colima-vs-podman-desktop-2l13</link>
      <guid>https://dev.to/libme/docker-desktop-alternatives-on-mac-orbstack-vs-colima-vs-podman-desktop-2l13</guid>
      <description>&lt;p&gt;If you want the lowest-friction swap, OrbStack is the closest drop-in for Docker Desktop on a Mac, with the licensing caveat that commercial use is paid. If you want free and open source with a CLI-first workflow, Colima keeps your existing &lt;code&gt;docker&lt;/code&gt; and &lt;code&gt;docker compose&lt;/code&gt; commands working. Podman Desktop is the right pick when rootless, daemonless containers are a requirement rather than a preference.&lt;/p&gt;

&lt;p&gt;This post is for the moment you are about to pay for Docker Desktop, or a licensing email has landed in your inbox, and you need to know what actually changes when you leave. Details are as of November 2026; licensing terms and features move, so check each vendor's page before you commit a team.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why look for an alternative at all?
&lt;/h2&gt;

&lt;p&gt;Three reasons come up repeatedly: licensing, resource usage, and file-sharing speed. Docker Desktop's subscription requirement applies to larger companies (Docker's published terms tie it to company size and revenue), while personal use, education, and small businesses are exempt. If you are a solo developer, the licence probably does not apply to you, and you should switch only for the other two reasons.&lt;/p&gt;

&lt;p&gt;On macOS every option here runs Linux containers inside a lightweight VM. The differences are which VM layer they use, how they share files with the host, and how much of the Docker developer experience they reproduce.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: on a Mac, "which Docker alternative" is really "which Linux VM manager and which file-sharing layer".&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do the three compare?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;OrbStack&lt;/th&gt;
&lt;th&gt;Colima&lt;/th&gt;
&lt;th&gt;Podman Desktop&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Licence model&lt;/td&gt;
&lt;td&gt;Proprietary, free for personal use, paid for commercial (check current terms)&lt;/td&gt;
&lt;td&gt;Open source (MIT)&lt;/td&gt;
&lt;td&gt;Open source (Apache 2.0)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Engine&lt;/td&gt;
&lt;td&gt;Docker-compatible engine&lt;/td&gt;
&lt;td&gt;Docker (default) or containerd, via Lima&lt;/td&gt;
&lt;td&gt;Podman, daemonless&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GUI&lt;/td&gt;
&lt;td&gt;Native Mac app&lt;/td&gt;
&lt;td&gt;None (CLI)&lt;/td&gt;
&lt;td&gt;Electron app&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;docker&lt;/code&gt; CLI compatibility&lt;/td&gt;
&lt;td&gt;Works out of the box&lt;/td&gt;
&lt;td&gt;Works via a Docker context&lt;/td&gt;
&lt;td&gt;Needs the socket compatibility mode or &lt;code&gt;podman&lt;/code&gt; alias&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compose&lt;/td&gt;
&lt;td&gt;&lt;code&gt;docker compose&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;docker compose&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;podman compose&lt;/code&gt; or a Docker-compatible socket&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local Kubernetes&lt;/td&gt;
&lt;td&gt;Built in, optional&lt;/td&gt;
&lt;td&gt;&lt;code&gt;colima start --kubernetes&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Via kind/minikube integrations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rootless by default&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Platform&lt;/td&gt;
&lt;td&gt;macOS only&lt;/td&gt;
&lt;td&gt;macOS and Linux&lt;/td&gt;
&lt;td&gt;macOS, Windows, Linux&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things the table hides. First, OrbStack is closed source, so you are trusting one vendor instead of a community project. Second, Podman is not Docker with a different logo: most commands translate, but anything that talks directly to &lt;code&gt;/var/run/docker.sock&lt;/code&gt; needs configuration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: the further you move from the Docker daemon, the more you pay in compatibility work.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you set up Colima without breaking existing scripts?
&lt;/h2&gt;

&lt;p&gt;Colima is the option I would try first if you are comfortable in a terminal, because your existing commands stay the same.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;colima docker docker-compose

&lt;span class="c"&gt;# vz = Apple's Virtualization framework; virtiofs is the faster mount type&lt;/span&gt;
colima start &lt;span class="nt"&gt;--cpu&lt;/span&gt; 4 &lt;span class="nt"&gt;--memory&lt;/span&gt; 8 &lt;span class="nt"&gt;--disk&lt;/span&gt; 60 &lt;span class="nt"&gt;--vm-type&lt;/span&gt; vz &lt;span class="nt"&gt;--mount-type&lt;/span&gt; virtiofs

docker context &lt;span class="nb"&gt;ls&lt;/span&gt;          &lt;span class="c"&gt;# a "colima" context should now be active&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; hello-world
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plan on running &lt;code&gt;colima start&lt;/code&gt; after every reboot, or register it as a Homebrew service with &lt;code&gt;brew services start colima&lt;/code&gt;. If you forget, every &lt;code&gt;docker&lt;/code&gt; command fails with a connection error against the socket, which is a confusing first-day symptom.&lt;/p&gt;

&lt;p&gt;The most common breakage is tooling that hardcodes the default socket path. Anything that does not read your Docker context needs to be pointed at Colima's socket explicitly:&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;export &lt;/span&gt;&lt;span class="nv"&gt;DOCKER_HOST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"unix://&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.colima/default/docker.sock"&lt;/span&gt;
&lt;span class="c"&gt;# Testcontainers has its own override for the socket it mounts into containers&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/var/run/docker.sock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without those, the symptom I would look for is a test suite that works under Docker Desktop and fails at startup with "Could not find a valid Docker environment".&lt;/p&gt;

&lt;p&gt;The honest drawback: Colima has no GUI and fewer guardrails. The VM's CPU, memory, and disk are fixed at start time, so if your builds run out of memory you restart the VM with new flags rather than dragging a slider.&lt;/p&gt;

&lt;p&gt;For a developer who wants to keep the Docker CLI and pay nothing, Colima is the option that changes the least while removing the subscription question.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: Colima keeps your commands, and the only migration cost is socket paths in tools that bypass your Docker context.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When is OrbStack worth it?
&lt;/h2&gt;

&lt;p&gt;OrbStack's pitch is speed and a native feel: quick startup, lower idle resource use, and fast file sharing, with a Mac-native app instead of an Electron one. In my experience the practical difference is largest on bind-mount-heavy workloads, such as a Node project with a large &lt;code&gt;node_modules&lt;/code&gt; mounted into a container. Benchmark it on your own project before believing anyone's numbers, including mine; I am not quoting figures because they vary heavily with the repo and the Mac.&lt;/p&gt;

&lt;p&gt;If you want a fast, polished Docker Desktop replacement and your employer will pay for the commercial licence, OrbStack is the one that feels least like a compromise. Its limits are real: macOS only (so your team's Linux and Windows developers need something else), closed source, and a licence you have to track.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: OrbStack buys polish and speed, and the price is a vendor dependency and a licence check for commercial use.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When does Podman Desktop make sense?
&lt;/h2&gt;

&lt;p&gt;Choose Podman when the security model is the point: no root daemon, rootless containers by default, and a CLI that mirrors Docker closely enough that &lt;code&gt;alias docker=podman&lt;/code&gt; covers most daily use.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;podman
podman machine init &lt;span class="nt"&gt;--cpus&lt;/span&gt; 4 &lt;span class="nt"&gt;--memory&lt;/span&gt; 8192
podman machine start
podman run &lt;span class="nt"&gt;--rm&lt;/span&gt; docker.io/library/hello-world
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the fully qualified image name. Podman may prompt you to pick a registry for short names, which breaks non-interactive scripts until you configure &lt;code&gt;unqualified-search-registries&lt;/code&gt;. Rootless mode also means you cannot bind ports below 1024 on Linux hosts without extra setup, and docker-compose-based projects sometimes need adjustment. If your team ships to environments where Podman is already the runtime, the parity benefit is genuine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: pick Podman for rootless and runtime parity, not as a licensing workaround, because it is the biggest behavioural change of the three.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How should you decide?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your situation&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Solo developer or small business under Docker's licence threshold&lt;/td&gt;
&lt;td&gt;Stay on Docker Desktop, or Colima if you prefer CLI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Company that needs to cut licence cost, team comfortable in a terminal&lt;/td&gt;
&lt;td&gt;Colima&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Company that will pay for polish and speed, macOS-only team&lt;/td&gt;
&lt;td&gt;OrbStack&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security or compliance requires rootless, or production runs Podman&lt;/td&gt;
&lt;td&gt;Podman Desktop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mixed Mac, Windows, Linux team needing one tool&lt;/td&gt;
&lt;td&gt;Podman Desktop or Docker Desktop&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is Colima a drop-in replacement for Docker Desktop?
&lt;/h3&gt;

&lt;p&gt;For the &lt;code&gt;docker&lt;/code&gt; and &lt;code&gt;docker compose&lt;/code&gt; CLI, yes, since Colima runs a real Docker engine in a VM and exposes it through a Docker context. It is not a drop-in for the GUI, Docker extensions, or tools that hardcode the default socket path.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Docker Desktop free?
&lt;/h3&gt;

&lt;p&gt;Docker's terms make it free for personal use, education, open source, and small businesses, and paid for larger companies. Check Docker's current subscription terms for the exact thresholds, since they are the ones that apply to you.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is OrbStack faster than Docker Desktop?
&lt;/h3&gt;

&lt;p&gt;It is commonly reported as faster at startup and file sharing, but the gain depends on your workload, so run your own build and test loop under both before deciding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;Solo developers under the licence threshold have no urgent reason to leave Docker Desktop. If you do want to leave, Colima is the safest default because your commands, Compose files, and mental model all carry over. If you want the smoothest Mac experience and someone else pays, OrbStack is the one to try. Choose Podman Desktop only when rootless containers or Podman parity in production is the actual requirement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/uptime-monitor-says-100-while-users-see-errors-what-to-check-and-when-paid-monitoring-is-worth-it-489p"&gt;Uptime Monitor Says 100% While Users See Errors: What to Check, and When Paid Monitoring Is Worth It&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/dev-environment-as-code-devcontainers-nix-or-just-a-good-makefile-3loi"&gt;Dev Environment as Code: Devcontainers, Nix, or Just a Good Makefile?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/s3-vs-r2-vs-b2-for-user-uploads-when-egress-fees-should-change-your-architecture-ip5"&gt;S3 vs R2 vs B2 for User Uploads: When Egress Fees Should Change Your Architecture&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>docker</category>
      <category>devops</category>
      <category>tooling</category>
      <category>productivity</category>
    </item>
    <item>
      <title>pip "externally-managed-environment" Error: venv, pipx, or uv?</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Mon, 02 Nov 2026 22:16:10 +0000</pubDate>
      <link>https://dev.to/libme/pip-externally-managed-environment-error-venv-pipx-or-uv-12e2</link>
      <guid>https://dev.to/libme/pip-externally-managed-environment-error-venv-pipx-or-uv-12e2</guid>
      <description>&lt;p&gt;If &lt;code&gt;pip install&lt;/code&gt; fails with &lt;code&gt;error: externally-managed-environment&lt;/code&gt;, your operating system's package manager owns that Python, and pip is refusing to write into it (PEP 668). The fix is almost never &lt;code&gt;--break-system-packages&lt;/code&gt;: use a virtual environment for project dependencies, &lt;code&gt;pipx&lt;/code&gt; or &lt;code&gt;uv tool&lt;/code&gt; for command-line applications, and reserve the override flag for throwaway containers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the error actually say, and why does it happen?
&lt;/h2&gt;

&lt;p&gt;On a recent Debian, Ubuntu, or Homebrew-installed Python, this is what you get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ pip install requests
error: externally-managed-environment

× This environment is externally managed
╰─&amp;gt; To install Python packages system-wide, try apt install
    python3-xyz, where xyz is the package you are trying to
    install.

    If you wish to install a non-Debian-packaged Python package,
    create a virtual environment using python3 -m venv path/to/venv.
    ...
note: If you believe this is a mistake, please contact your Python installation or OS distribution provider. You can override this, at your own risk, by passing the --break-system-packages flag.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mechanism is a marker file. Distributions that ship PEP 668 support place a file named &lt;code&gt;EXTERNALLY-MANAGED&lt;/code&gt; in the interpreter's standard library directory, and pip checks for it before any install into that environment. You can see it yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import sysconfig, pathlib; p = pathlib.Path(sysconfig.get_path('stdlib')) / 'EXTERNALLY-MANAGED'; print(p, p.exists())"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason it exists is real: &lt;code&gt;apt&lt;/code&gt; and &lt;code&gt;pip&lt;/code&gt; both write into the same &lt;code&gt;site-packages&lt;/code&gt;, and when pip upgrades a library that an OS tool depends on, the OS tool can break in ways that are miserable to diagnose. As of late 2026 this behavior is the default on current Debian and Ubuntu releases, and Homebrew's Python applies it too. It is not a pip bug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: the error means "this interpreter belongs to your OS", and the right response is to stop installing into it, not to silence the check.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Which dead ends should you skip?
&lt;/h2&gt;

&lt;p&gt;These are the ones I see suggested most often, and why they cost you later:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sudo pip install ...&lt;/code&gt;&lt;/strong&gt; does not bypass the check, and if it did, you would be writing into the exact directory the protection exists for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deleting the &lt;code&gt;EXTERNALLY-MANAGED&lt;/code&gt; file&lt;/strong&gt; works until the next Python package update puts it back, and in the meantime you have turned off the guard for every user on the machine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pip install --user&lt;/code&gt;&lt;/strong&gt; is blocked by the same marker on most setups, so it does not help.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--break-system-packages&lt;/code&gt;&lt;/strong&gt; works, and on a disposable machine that is fine. On a laptop or server you care about, it reintroduces the exact apt-versus-pip conflicts PEP 668 prevents.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: every shortcut here trades a one-minute fix now for an unexplainable breakage of an OS tool later.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Which tool fits which job?
&lt;/h2&gt;

&lt;p&gt;The right choice depends on what you are installing, not on personal taste.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Best fit&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Dependencies for a project you are developing&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;venv&lt;/code&gt; (or &lt;code&gt;uv venv&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;One isolated environment per project, reproducible from a requirements file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A CLI tool you just want on your PATH (e.g. a formatter, &lt;code&gt;httpie&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;pipx&lt;/code&gt; or &lt;code&gt;uv tool install&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Each app gets its own hidden environment, the command lands on PATH&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Many projects, slow installs, lockfile wanted&lt;/td&gt;
&lt;td&gt;&lt;code&gt;uv&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fast resolver and installer, handles Python versions too&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Docker image or CI job that is thrown away&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;venv&lt;/code&gt;, or the override flag&lt;/td&gt;
&lt;td&gt;The OS-protection argument is weaker in a disposable root filesystem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A library packaged by your distro&lt;/td&gt;
&lt;td&gt;&lt;code&gt;apt install python3-&amp;lt;name&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Managed and patched by the OS, but often older&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: libraries go in a project venv, applications go in an isolated tool environment, and neither goes in the system interpreter.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you set up a venv correctly (including the Debian gotcha)?
&lt;/h2&gt;

&lt;p&gt;The standard library route needs no extra tools:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv
&lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install &lt;/span&gt;requests
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Debian and Ubuntu you may hit a second error first: &lt;code&gt;The virtual environment was not created successfully because ensurepip is not available&lt;/code&gt;. Debian splits &lt;code&gt;venv&lt;/code&gt; into its own package, so run &lt;code&gt;sudo apt install python3-venv&lt;/code&gt; and retry.&lt;/p&gt;

&lt;p&gt;Do not skip &lt;code&gt;source .venv/bin/activate&lt;/code&gt;. If you forget, &lt;code&gt;pip&lt;/code&gt; resolves to the system one and you are back at the original error. When in doubt, call the venv's interpreter explicitly: &lt;code&gt;.venv/bin/python -m pip install requests&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you want the managed version of this workflow, uv is the tool that creates the environment and installs from a requirements file in one fast step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv venv
uv pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A genuine drawback: &lt;code&gt;uv&lt;/code&gt; is a separate binary you have to install and trust, and its &lt;code&gt;uv pip&lt;/code&gt; interface follows pip closely but not identically, so edge cases in older requirements files can resolve differently than they did under pip. Test before you swap it into CI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: the venv is only protecting you if it is active or invoked by path; most "it still fails" reports are an unactivated shell.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you install command-line tools without polluting anything?
&lt;/h2&gt;

&lt;p&gt;For applications, &lt;code&gt;pipx&lt;/code&gt; creates one isolated environment per tool and exposes only its executables:&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;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;pipx   &lt;span class="c"&gt;# or: brew install pipx&lt;/span&gt;
pipx ensurepath
pipx &lt;span class="nb"&gt;install &lt;/span&gt;httpie
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want the one that handles isolated tool installs without a separate package from your distro, &lt;code&gt;uv tool install httpie&lt;/code&gt; does the same job with the same one-environment-per-app model.&lt;/p&gt;

&lt;p&gt;The limitation of both: they are for applications. If you &lt;code&gt;pipx install&lt;/code&gt; a library and then &lt;code&gt;import&lt;/code&gt; it in your own script, it will not be importable, because the library lives in its hidden environment. That is by design, and it is the most common confusion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: if you will type its name in a terminal, use pipx or uv tool; if you will &lt;code&gt;import&lt;/code&gt; it, use a venv.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When is &lt;code&gt;--break-system-packages&lt;/code&gt; acceptable?
&lt;/h2&gt;

&lt;p&gt;In a Dockerfile or CI runner that is rebuilt from scratch every time, there is no OS tool left to protect, and a venv adds a layer for little benefit. Setting &lt;code&gt;ENV PIP_BREAK_SYSTEM_PACKAGES=1&lt;/code&gt; in the image is a reasonable, explicit choice there. Even so, a venv in the image keeps the same code path you use locally, which makes "works on my machine" bugs rarer. Choose the override flag knowingly, not because a forum answer said so.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: the override flag is a tool for disposable filesystems, not for your daily laptop.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How do I fix "error: externally-managed-environment" on Ubuntu or Debian?
&lt;/h3&gt;

&lt;p&gt;Create a virtual environment with &lt;code&gt;python3 -m venv .venv&lt;/code&gt;, activate it, and run pip inside it. If venv creation fails, install &lt;code&gt;python3-venv&lt;/code&gt; with apt first.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is &lt;code&gt;pip install --break-system-packages&lt;/code&gt; safe?
&lt;/h3&gt;

&lt;p&gt;It is safe on a throwaway container or CI runner. On a machine where apt manages Python tools, it can overwrite files that OS utilities depend on and cause breakage that is hard to trace.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use pipx or uv to install Python command-line tools?
&lt;/h3&gt;

&lt;p&gt;Either works: both give each application its own isolated environment and put its command on your PATH. Choose &lt;code&gt;uv tool&lt;/code&gt; if you already use uv for projects, otherwise &lt;code&gt;pipx&lt;/code&gt; is available from most package managers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;Use a venv (or &lt;code&gt;uv venv&lt;/code&gt;) for anything you import, &lt;code&gt;pipx&lt;/code&gt; or &lt;code&gt;uv tool install&lt;/code&gt; for anything you run from a terminal, and &lt;code&gt;apt install python3-&amp;lt;name&amp;gt;&lt;/code&gt; only when you want the distro-maintained version. Treat &lt;code&gt;--break-system-packages&lt;/code&gt; as a Docker-and-CI setting. If you are on a team, put the one-line environment setup in the README so nobody rediscovers this error on day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/postman-vs-bruno-vs-hoppscotch-does-your-api-client-really-need-a-cloud-account-34bk"&gt;Postman vs Bruno vs Hoppscotch: Does Your API Client Really Need a Cloud Account?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/cutting-your-side-projects-cloud-bill-a-checklist-that-doesnt-sacrifice-uptime-17kf"&gt;Cutting Your Side Project's Cloud Bill: A Checklist That Doesn't Sacrifice Uptime&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/nodejs-fetch-failed-with-etimedout-or-enetunreach-while-curl-works-the-broken-ipv6-route-7g7"&gt;Node.js "fetch failed" with ETIMEDOUT or ENETUNREACH While curl Works: The Broken IPv6 Route&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>python</category>
      <category>tutorial</category>
      <category>tooling</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
