<?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>When Should You Move Off Managed Hosting to Kubernetes? The Thresholds That Actually Matter</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Thu, 20 Aug 2026 08:42:41 +0000</pubDate>
      <link>https://dev.to/libme/when-should-you-move-off-managed-hosting-to-kubernetes-the-thresholds-that-actually-matter-5bbl</link>
      <guid>https://dev.to/libme/when-should-you-move-off-managed-hosting-to-kubernetes-the-thresholds-that-actually-matter-5bbl</guid>
      <description>&lt;p&gt;If you are trying to decide whether it is finally time to run a cluster, the honest answer is that request volume is almost never the trigger. The triggers are structural: per-tenant isolation you cannot express in your database, workloads you do not trust, a compliance boundary that requires network-level segmentation with an audit trail, or a pile of homegrown scripts that have quietly become a bad orchestrator. Before any of those, you should be able to prove you have run out of headroom on the managed platform you already pay for.&lt;/p&gt;

&lt;p&gt;A reader on an earlier post about boring stacks made the sharpest version of this point: the real failure mode that pushes teams to Kubernetes is not high traffic, it is complex multi-tenant stateful workloads and compliance boundaries that a managed platform cannot express. They then asked the question this post exists to answer — what specific metrics and team thresholds should sit in the decision table. Here is the one I use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is "we're getting too much traffic" almost never the real trigger?
&lt;/h2&gt;

&lt;p&gt;Because managed platforms scale vertically and horizontally long past where most teams assume they stop. A single large instance running a well-tuned application server handles a volume of traffic that surprises people who have only ever read about scale, and every managed runtime worth using will run several identical copies of your container behind a load balancer without you learning a new config language.&lt;/p&gt;

&lt;p&gt;Before you accept "we need to scale" as an argument, measure your actual headroom. The database is usually the real ceiling, and it is the one people misread most often — an app that falls over at 300 concurrent requests is frequently exhausting Postgres connections, not CPU:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;setting&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_settings&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'max_connections'&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;max_connections&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;in_use&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&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;setting&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_settings&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'max_connections'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;pct_used&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that comes back at 95% while your instance CPU sits at 20%, Kubernetes solves nothing for you. A connection pooler and a smaller per-process pool size solve it in an afternoon. The same applies to the app tier: if your peak CPU utilization never crosses 40% on the instance size you are paying for, you do not have a scaling problem, you have a latency or concurrency problem hiding behind one.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If you cannot point at a specific resource you have saturated after tuning, you do not have a scaling argument — you have a discomfort.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What are the structural signals that actually justify a cluster?
&lt;/h2&gt;

&lt;p&gt;These are the four I treat as genuine. Each one has a test you can apply today, and each one has a boring-stack workaround you should exhaust first.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;How to test it&lt;/th&gt;
&lt;th&gt;Try this first&lt;/th&gt;
&lt;th&gt;If that fails&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Per-tenant stateful isolation&lt;/td&gt;
&lt;td&gt;Can one tenant's data or load reach another's, in a way a customer contract forbids?&lt;/td&gt;
&lt;td&gt;Row-level security, or a schema/database per tenant on managed Postgres&lt;/td&gt;
&lt;td&gt;Per-tenant namespaces with resource quotas&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Untrusted or arbitrary workloads&lt;/td&gt;
&lt;td&gt;Are you executing code you did not write — customer plugins, build jobs, notebooks?&lt;/td&gt;
&lt;td&gt;Managed sandbox/build services, hard per-job timeouts&lt;/td&gt;
&lt;td&gt;Real pod-level isolation and admission control&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compliance boundary&lt;/td&gt;
&lt;td&gt;Does an auditor need to see enforced network segmentation and deploy provenance?&lt;/td&gt;
&lt;td&gt;Separate managed environments per boundary, VPC peering rules&lt;/td&gt;
&lt;td&gt;Network policies, RBAC, signed image admission&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy coordination&lt;/td&gt;
&lt;td&gt;Count the lines of custom bash that restart, health-check, and roll out services&lt;/td&gt;
&lt;td&gt;A managed runtime with health checks and rolling deploys&lt;/td&gt;
&lt;td&gt;Declarative orchestration&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The multi-tenancy row is where most teams misdiagnose themselves, so it deserves the concrete version. "Multi-tenant stateful workload" usually means shared rows in shared tables, and that is a database problem with a database answer:&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;invoices&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="k"&gt;FORCE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;tenant_isolation&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'app.tenant_id'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then set the tenant once per transaction, from your connection checkout or request middleware:&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;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'3f1c2a9e-0b47-4f8a-9d21-5c6e8f0a1b23'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'open'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;FORCE ROW LEVEL SECURITY&lt;/code&gt; matters because policies are skipped for the table owner by default, which is exactly the role most applications connect as — that omission is the single most common way an RLS setup looks correct and enforces nothing. &lt;code&gt;SET LOCAL&lt;/code&gt; scopes the setting to the transaction so a pooled connection cannot leak one tenant's context into the next request.&lt;/p&gt;

&lt;p&gt;If that satisfies your isolation requirement, you did not need an orchestrator, you needed twelve lines of SQL. If your requirement is instead "tenant A's batch job must never consume tenant B's CPU," you have a genuine scheduling problem and the case for Kubernetes gets real.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Isolation requirements that live in your data model are database work; isolation requirements that live in the kernel are orchestrator work.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What team size can actually carry a cluster?
&lt;/h2&gt;

&lt;p&gt;The number I use is not a headcount, it is an ownership commitment: someone whose job description includes the cluster, with roughly half a day a week of unglamorous maintenance budgeted, and at least one other person who can do a node upgrade when that person is on vacation. On a team of five engineers that is 10% of your engineering capacity gone, permanently, to something your customers never see.&lt;/p&gt;

&lt;p&gt;The recurring work is real and it does not go away with a managed control plane: node group upgrades on the provider's deprecation schedule, ingress controller and cert-manager version bumps, CNI plugin compatibility, RBAC drift, and the periodic afternoon spent discovering that a pod is pending because of a resource request nobody remembers writing. Managed offerings like EKS and GKE remove the control plane from your worry list and genuinely lower the floor, but the worker nodes, networking, and deploy pipeline are still yours.&lt;/p&gt;

&lt;p&gt;There is also a middle tier that most decision tables skip entirely. If your only real need is "run these containers, restart them when they die, roll them out without downtime," AWS Fargate gives you container scheduling with no nodes to patch, at the cost of slower cold starts and a weaker local development story. If your workloads are HTTP services that can tolerate scale-to-zero, Google Cloud Run handles request-driven autoscaling and TLS termination without exposing you to any cluster concepts at all, though it constrains you to its request lifecycle. If you want orchestration primitives without the Kubernetes ecosystem surface area, HashiCorp Nomad schedules containers and plain binaries with a config format a new engineer can read in an afternoon, with the tradeoff of a much smaller community and fewer off-the-shelf integrations.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Adopt Kubernetes when you need what only Kubernetes gives you, not when you need what any scheduler gives you.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How do you make the switch without a big-bang migration?
&lt;/h2&gt;

&lt;p&gt;Move one stateless service first, and pick the least important one you have — an internal admin tool, a metrics exporter, a webhook receiver. It should be something whose outage costs you an apology, not revenue. That first workload is how you discover the parts nobody writes tickets for: image pull secrets, DNS resolution inside the cluster, log shipping, and how you actually get a shell when something breaks.&lt;/p&gt;

&lt;p&gt;Keep state outside the cluster for as long as you can. Managed Postgres, managed object storage, and managed queues stay exactly where they are; running your own database inside Kubernetes is a separate project with its own operator, backup, and failover story, and taking both on at once is how migrations stall for a quarter.&lt;/p&gt;

&lt;p&gt;Set an explicit rollback condition before you start — something like "if the admin tool is not stable on the cluster within three weeks, it goes back." Migrations without a stated failure condition tend to continue on sunk cost alone.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Your first cluster workload should be chosen for how little it matters, not how well it demonstrates the platform.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;&lt;strong&gt;How many services do you need before Kubernetes is worth it?&lt;/strong&gt;&lt;br&gt;
There is no clean count, but the useful proxy is deploy coordination, not service count. If you are running fewer than roughly five services and your deploys are independent, a managed runtime handles it; once services must be rolled out in a specific order, share service discovery, and are held together by custom scripts you are afraid to edit, the orchestrator is doing work you are currently doing by hand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Kubernetes required for SOC 2 or HIPAA compliance?&lt;/strong&gt;&lt;br&gt;
No. Neither framework names any orchestrator. What auditors ask for is enforced access control, network segmentation between environments, encryption, and evidence of change management — all of which managed platforms can satisfy with separate environments and provider-level controls. Kubernetes becomes relevant when your segmentation requirements are finer-grained than the boundaries your platform lets you draw.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can you run a multi-tenant SaaS without Kubernetes?&lt;/strong&gt;&lt;br&gt;
Yes, and most do. Tenant isolation at the data layer with row-level security or a database per tenant covers the majority of contractual requirements. You need kernel-level isolation only when tenants can trigger workloads that compete for CPU and memory, or when you execute code the tenant supplied.&lt;/p&gt;

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

&lt;p&gt;If you are on a managed platform and shopping for a reason to leave it, measure your headroom first — connection saturation, CPU utilization at peak, and how much of your latency is your own code. Adopt Kubernetes when you hit a structural wall: untrusted workloads, isolation that must be enforced below your application, or an audit boundary your platform cannot draw. If you need scheduling but not the full ecosystem, Fargate, Cloud Run, and Nomad are the honest middle, and each costs you flexibility in exchange for the operational load it removes. And if nobody on your team can name the person who owns node upgrades six months from now, the answer for today is still no.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/the-boring-stack-manifesto-why-your-startup-probably-doesnt-need-kubernetes-55bo"&gt;The Boring Stack Manifesto: Why Your Startup Probably Doesn't Need Kubernetes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/netlify-pros-and-cons-when-its-the-right-host-and-when-youll-outgrow-it-2ka1"&gt;Netlify Pros and Cons: When It's the Right Host, and When You'll Outgrow It&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;/ul&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>architecture</category>
      <category>cloud</category>
    </item>
    <item>
      <title>Where Should a Small Team Store Its Secrets? Comparing .env Files, SOPS, Doppler, 1Password, and Vault</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Wed, 19 Aug 2026 21:42:06 +0000</pubDate>
      <link>https://dev.to/libme/where-should-a-small-team-store-its-secrets-comparing-env-files-sops-doppler-1password-and-e13</link>
      <guid>https://dev.to/libme/where-should-a-small-team-store-its-secrets-comparing-env-files-sops-doppler-1password-and-e13</guid>
      <description>&lt;p&gt;If your team is under about ten people and already runs on one cloud provider, the cheapest correct answer is your provider's own secret store plus OIDC in CI, and no long-lived keys anywhere. If you're spread across several providers or hand secrets to non-infra teammates, a hosted secrets manager with a CLI injector — Doppler or 1Password — pays for itself the first time a rotation doesn't take an afternoon. Encrypted files in git (SOPS) are the right answer for a narrow case: config that must version alongside code, reviewed in PRs.&lt;/p&gt;

&lt;p&gt;What almost never works past the second engineer is passing &lt;code&gt;.env&lt;/code&gt; files around in Slack. Here's why, concretely, and what to replace it with.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode that ends the .env era
&lt;/h2&gt;

&lt;p&gt;The bug that finally forced this decision on me wasn't a breach. It was a deploy that came up healthy and served traffic against the wrong database for twenty minutes.&lt;/p&gt;

&lt;p&gt;The shape is always the same:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// db.js — the line that costs you an incident&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;postgres://localhost:5432/app_dev&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A secret gets renamed in one place and not another, or a CI job runs before the env is populated, and &lt;code&gt;process.env.DATABASE_URL&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt;. The &lt;code&gt;||&lt;/code&gt; fallback is silent by design. Nothing throws, health checks pass, and the failure only surfaces when someone notices writes going nowhere.&lt;/p&gt;

&lt;p&gt;Two fixes, and you want both. First, fail fast at boot so an absent secret is a crash, not a fallback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// env.js — parse once, at startup, before anything connects&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;z&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="s2"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sk_&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;SESSION_SECRET&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safeParse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid environment:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatten&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;fieldErrors&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&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="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Import &lt;code&gt;env&lt;/code&gt; everywhere instead of touching &lt;code&gt;process.env&lt;/code&gt; directly, and add a lint rule banning &lt;code&gt;process.env&lt;/code&gt; outside that one file. Now a missing secret is a loud, immediate, unambiguous crash.&lt;/p&gt;

&lt;p&gt;Second, remove the drift itself by having exactly one place a secret lives. That's the actual decision below.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fail-fast env parsing is the highest-leverage twenty lines in this entire post — do it before you pick a vendor, because it turns every secrets bug from a silent wrong answer into a stack trace.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What are the real options, and what does each cost you?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Best when&lt;/th&gt;
&lt;th&gt;Real drawback&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.env&lt;/code&gt; files, shared manually&lt;/td&gt;
&lt;td&gt;Solo, one machine, throwaway projects&lt;/td&gt;
&lt;td&gt;No rotation story, no audit trail, leaks via Slack/backups&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SOPS + age/KMS, committed to git&lt;/td&gt;
&lt;td&gt;Config that must version with code and be PR-reviewed&lt;/td&gt;
&lt;td&gt;Rotation = commit + redeploy; key management is on you&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cloud-native (AWS Secrets Manager, Parameter Store, GCP Secret Manager, Azure Key Vault)&lt;/td&gt;
&lt;td&gt;Already all-in on one cloud&lt;/td&gt;
&lt;td&gt;Clumsy for local dev; per-secret and per-API-call billing adds up at high call volume&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Doppler&lt;/td&gt;
&lt;td&gt;Multi-provider deploys, want a fast CLI + integrations&lt;/td&gt;
&lt;td&gt;Another vendor in your boot path; SaaS-first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1Password Secrets Automation&lt;/td&gt;
&lt;td&gt;Team already lives in 1Password; humans and machines need the same vault&lt;/td&gt;
&lt;td&gt;Service-account model takes a beat to grasp; usage-metered&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vault / OpenBao (self-hosted)&lt;/td&gt;
&lt;td&gt;Dynamic short-lived DB creds, strict compliance&lt;/td&gt;
&lt;td&gt;Genuine operational burden — unseal, HA, upgrades&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few honest notes on each, since the marketing pages won't give you these.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloud-native stores&lt;/strong&gt; are the default nobody regrets on cost or reliability, and AWS Systems Manager Parameter Store's standard tier in particular is the underrated option — plain-string parameters with KMS encryption, no per-secret monthly charge on the standard tier as of mid-2026 (Secrets Manager bills per secret per month plus API calls, so check your call pattern before assuming it's cheap). The pain is local development: your laptop now needs cloud credentials to boot the app, which is exactly the long-lived key you were trying to eliminate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Doppler&lt;/strong&gt; is the one that handles the "same secret, five environments, three deploy targets" problem without a bespoke sync script, and its CLI injects secrets as environment variables for the duration of a process:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;doppler run &lt;span class="nt"&gt;--project&lt;/span&gt; api &lt;span class="nt"&gt;--config&lt;/span&gt; dev &lt;span class="nt"&gt;--&lt;/span&gt; node server.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing lands on disk, and &lt;code&gt;doppler run&lt;/code&gt; composes with whatever your app already expects. The drawback is real: you've added a network dependency to your startup path, and you should understand its cached-fallback behavior before you put it in front of production boots.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1Password Secrets Automation&lt;/strong&gt; is the right pick when the same credential needs to be readable by a person during an incident and by a machine during a deploy, because it's one vault with one audit log for both. The CLI resolves &lt;code&gt;op://&lt;/code&gt; references at launch:&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;# .env.template — safe to commit; contains references, not values&lt;/span&gt;
&lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;op://prod/postgres/url
&lt;span class="nv"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;op://prod/stripe/secret_key
&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;op run &lt;span class="nt"&gt;--env-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;.env.template &lt;span class="nt"&gt;--&lt;/span&gt; node server.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Committing a template of &lt;em&gt;references&lt;/em&gt; is the part that quietly fixes onboarding — a new hire clones, runs, and gets the right values without anyone DM'ing them a file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vault (or OpenBao, the Linux Foundation fork created after HashiCorp's 2023 license change)&lt;/strong&gt; earns its complexity on exactly one feature: dynamic secrets. It can mint a Postgres user valid for an hour and revoke it automatically, so a leaked credential expires on its own. If nobody on your team wants to own unseal keys and HA topology, use the managed offering or don't use Vault — a badly-run Vault is worse than Parameter Store.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vault is a correct answer to a question most small teams don't have yet; the question it answers is "how do I make leaked credentials expire by themselves."&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you get secrets into CI without storing secrets in CI?
&lt;/h2&gt;

&lt;p&gt;This is the part teams skip, and it's where the highest-value credentials sit. A long-lived cloud access key pasted into repository secrets is the single most valuable thing an attacker can get from your CI — it survives rotation of everything else.&lt;/p&gt;

&lt;p&gt;Use OIDC federation instead. GitHub Actions can exchange a short-lived workflow identity token for cloud credentials, so no static key exists to steal:&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;permissions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;id-token&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;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;deploy&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;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;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aws-actions/configure-aws-credentials@v4&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;role-to-assume&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;arn:aws:iam::123456789012:role/deploy&lt;/span&gt;
          &lt;span class="na"&gt;aws-region&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;us-east-1&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./deploy.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trust policy on that IAM role should pin the repository &lt;em&gt;and&lt;/em&gt; the branch or environment — a wildcard subject condition means any workflow in your org can assume it. GitLab CI, CircleCI, and Buildkite all have equivalent OIDC flows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If your CI still holds a long-lived cloud key in 2026, fixing that beats every other item on this list.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When is an encrypted-file approach (SOPS) actually right?
&lt;/h2&gt;

&lt;p&gt;SOPS encrypts only the &lt;em&gt;values&lt;/em&gt; in a YAML/JSON file, leaving keys readable, so a diff still shows which setting changed without revealing anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sops &lt;span class="nt"&gt;--encrypt&lt;/span&gt; &lt;span class="nt"&gt;--age&lt;/span&gt; age1ql3z... secrets.dev.yaml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; secrets.dev.enc.yaml
sops &lt;span class="nt"&gt;--decrypt&lt;/span&gt; secrets.dev.enc.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That property — reviewable diffs — is the whole argument. It fits GitOps and Kubernetes flows where config already ships through git, and it works offline with no vendor in the boot path.&lt;/p&gt;

&lt;p&gt;The cost is that rotation means a commit, a merge, and a deploy, and every old value stays in git history forever. Once a secret has been in a repo, rotating it is the only real remediation — deleting the commit is not, since clones and forks keep the object. Treat SOPS as configuration-that-happens-to-be-sensitive, not as a credential vault.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;How should a small startup manage secrets across dev, staging, and production?&lt;/strong&gt;&lt;br&gt;
One store, three scopes, no files on laptops. Use your cloud provider's secret store if you're single-cloud, or Doppler/1Password if you aren't, and inject values into the process at launch instead of writing &lt;code&gt;.env&lt;/code&gt; files to disk. Validate every required variable at boot so a missing secret crashes instead of falling back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is it safe to commit a .env file if it's encrypted?&lt;/strong&gt;&lt;br&gt;
Encrypted with SOPS or git-crypt, yes, with two caveats: the decryption key must live outside the repo, and any value that has ever been committed in plaintext must be rotated, because git history and existing clones keep it permanently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need HashiCorp Vault for a five-person team?&lt;/strong&gt;&lt;br&gt;
Almost certainly not. Vault's payoff is dynamic, short-lived credentials and fine-grained policy; below that bar, a managed secret store plus OIDC in CI gives you most of the security benefit with none of the unseal-and-HA operational burden.&lt;/p&gt;

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

&lt;p&gt;Single-cloud teams should use their provider's secret store with OIDC in CI and stop there — it's the lowest-cost, lowest-drama option, and it removes the long-lived keys that actually get exploited. Teams deploying across several platforms, or handing credentials to people who don't touch infrastructure, get real time back from Doppler or 1Password's CLI injection. Reach for SOPS when sensitive config genuinely needs to be reviewed in pull requests, and for Vault or OpenBao only when you specifically want credentials that expire on their own. Whatever you choose, the boot-time validation and the CI key removal matter more than the vendor.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/how-to-test-search-relevance-before-you-ship-a-ranking-change-29o"&gt;How to Test Search Relevance Before You Ship a Ranking Change&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/build-vs-buy-authentication-in-2026-auth0-clerk-supabase-auth-or-roll-your-own-3mc8"&gt;Build vs Buy: Authentication in 2026 (Auth0, Clerk, Supabase Auth, or Roll Your Own)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/cloudflare-for-developers-what-its-great-at-where-it-bites-and-how-to-actually-use-it-16k6"&gt;Cloudflare for Developers: What It's Great At, Where It Bites, and How to Actually Use It&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>cicd</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Your Postgres Migration Runner Needs a Retry Contract, Not Just a Lock Timeout</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Mon, 17 Aug 2026 15:23:11 +0000</pubDate>
      <link>https://dev.to/libme/your-postgres-migration-runner-needs-a-retry-contract-not-just-a-lock-timeout-1oii</link>
      <guid>https://dev.to/libme/your-postgres-migration-runner-needs-a-retry-contract-not-just-a-lock-timeout-1oii</guid>
      <description>&lt;p&gt;Setting &lt;code&gt;lock_timeout&lt;/code&gt; on a migration keeps a blocked &lt;code&gt;ALTER TABLE&lt;/code&gt; from freezing your traffic, but on its own it just converts one outage into a flaky deploy. The complete version is five rules: exactly one migrator at a time (advisory lock), a short lock timeout scoped to the transaction, the blocker logged before every retry, jittered backoff so replicas don't wake in lockstep, and a wall-clock budget after which the deploy fails instead of retrying forever. A reader raised this on an earlier post about migration locks, and it's the part most runners get wrong.&lt;/p&gt;

&lt;p&gt;Everything here holds for Postgres 12 and up, as of mid-2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do several replicas try to run the same migration at once?
&lt;/h2&gt;

&lt;p&gt;Most deploy systems run migrations as a pre-start step in the application container. Roll out four replicas and you have four processes racing to apply the same DDL. Usually the migration table's own row lock hides this, and you never notice.&lt;/p&gt;

&lt;p&gt;You notice when the DDL is blocked. All four sit in the lock queue behind the same idle-in-transaction session. Then the blocker commits, all four wake, and whichever loses the race hits a duplicate-object error or, worse, a partially applied migration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR:  column "shipped_at" of relation "orders" already exists
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the deploy is red for a reason that has nothing to do with the real problem. Worse, if you added deterministic exponential backoff — sleep 1s, 2s, 4s — every replica computes the &lt;em&gt;same&lt;/em&gt; delays, so they retry in unison and keep colliding on the same schedule. Backoff without jitter is a synchronized herd.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: a blocked migration turns a harmless replica race into a deploy failure, so serialization has to happen before the retry logic, not after.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I make sure only one migrator runs at a time?
&lt;/h2&gt;

&lt;p&gt;Postgres advisory locks. They're application-defined locks on an arbitrary &lt;code&gt;bigint&lt;/code&gt; key, scoped to the database, with no table involved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- session-level: held until unlocked or the connection closes&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_try_advisory_lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8675309&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;-- true = you are the migrator&lt;/span&gt;
&lt;span class="c1"&gt;-- ...run migrations...&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pg_advisory_unlock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8675309&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;pg_try_advisory_lock&lt;/code&gt; (returns immediately) rather than &lt;code&gt;pg_advisory_lock&lt;/code&gt; (waits forever) so a losing replica can decide what to do rather than hanging. Two reasonable choices: exit 0 and let the winner apply the schema, or wait a bounded time if the replica cannot safely serve traffic against the old schema. Pick one deliberately — silently exiting 0 is a real footgun when the new code needs the new column.&lt;/p&gt;

&lt;p&gt;One deployment detail decides which lock scope you can use. If your connection goes through &lt;strong&gt;PgBouncer&lt;/strong&gt; in transaction pooling mode, session-level advisory locks are unsafe, because the connection returns to the pool between transactions while the lock is still attached to it. There, use &lt;code&gt;pg_advisory_xact_lock(8675309)&lt;/code&gt; inside the migration transaction, which Postgres releases automatically at commit or rollback.&lt;/p&gt;

&lt;p&gt;Some runners already do this for you, and it's worth checking before you build your own:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Runner&lt;/th&gt;
&lt;th&gt;Cross-process locking (as of mid-2026)&lt;/th&gt;
&lt;th&gt;Watch out for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Flyway&lt;/td&gt;
&lt;td&gt;Yes, database-level lock on Postgres&lt;/td&gt;
&lt;td&gt;Verify behavior when running through a transaction pooler&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Liquibase&lt;/td&gt;
&lt;td&gt;Yes, via a &lt;code&gt;DATABASECHANGELOGLOCK&lt;/code&gt; row&lt;/td&gt;
&lt;td&gt;A killed run can leave the lock row set; needs manual release&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rails Active Record&lt;/td&gt;
&lt;td&gt;Yes, advisory lock by default&lt;/td&gt;
&lt;td&gt;Can be disabled in config; check it wasn't turned off&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;golang-migrate&lt;/td&gt;
&lt;td&gt;Yes, advisory lock on the Postgres driver&lt;/td&gt;
&lt;td&gt;Lock is per-database, so shared databases share the lock&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Alembic&lt;/td&gt;
&lt;td&gt;No built-in lock&lt;/td&gt;
&lt;td&gt;Serialization is your job — wrap the runner yourself&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you want serialization without writing a runner, &lt;strong&gt;Flyway&lt;/strong&gt; is the one that takes a database lock for you and fails the second process cleanly instead of letting it race.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: check whether your migration tool already serializes runs before adding your own advisory lock — two locking schemes are not safer than one.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where should lock_timeout actually be set?
&lt;/h2&gt;

&lt;p&gt;Inside the transaction, with &lt;code&gt;SET LOCAL&lt;/code&gt;, so it evaporates at commit:&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;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;lock_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'3s'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="n"&gt;application_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'migrator:add_shipped_at'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;shipped_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason to prefer &lt;code&gt;SET LOCAL&lt;/code&gt; over a plain &lt;code&gt;SET&lt;/code&gt; is pooling: a plain &lt;code&gt;SET&lt;/code&gt; sticks to the backend, and a pooled connection can hand that 3-second timeout to unrelated application queries later. The distinctive &lt;code&gt;application_name&lt;/code&gt; costs nothing and makes the blocker/waiter pair obvious in &lt;code&gt;pg_stat_activity&lt;/code&gt; at 2am.&lt;/p&gt;

&lt;p&gt;Two exceptions worth knowing. &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; cannot run inside a transaction block, so &lt;code&gt;SET LOCAL&lt;/code&gt; doesn't apply — issue a session-level &lt;code&gt;SET lock_timeout&lt;/code&gt; on that connection instead. And don't wrap &lt;code&gt;CONCURRENTLY&lt;/code&gt; work in a tight &lt;code&gt;statement_timeout&lt;/code&gt;: if the timeout kills it, you're left with an &lt;code&gt;INVALID&lt;/code&gt; index that you must drop before retrying.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;indexrelid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_index&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;indisvalid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Takeaway: &lt;code&gt;SET LOCAL lock_timeout&lt;/code&gt; bounds lock acquisition for this transaction only, which is exactly the scope you want on a pooled connection.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How should the retry loop back off, and what should it log first?
&lt;/h2&gt;

&lt;p&gt;Full jitter — sleep a uniform random amount between zero and the current exponential cap — is what breaks the synchronized herd. And before each sleep, capture &lt;em&gt;who&lt;/em&gt; blocked you, because a retry that succeeds tells you nothing about the query that will block tomorrow's migration too.&lt;/p&gt;

&lt;p&gt;There's a catch people get wrong: you cannot call &lt;code&gt;pg_blocking_pids(pg_backend_pid())&lt;/code&gt; from the connection that is currently blocked — it's busy waiting. Sampling it live requires a second observer connection. The cheap approximation is to probe right after the &lt;code&gt;lock_timeout&lt;/code&gt; fires, from the same connection, for the oldest transactions holding locks on that 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;SELECT&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;application_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;xact_start&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;xact_age&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;80&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;query&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_locks&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public.orders'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pg_backend_pid&lt;/span&gt;&lt;span class="p"&gt;()&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;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;xact_start&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Occasionally the blocker has already finished and you log nothing. That's an acceptable trade for not running an observer thread. Here is the whole contract in one runner, using psycopg 3:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;psycopg&lt;/span&gt;

&lt;span class="n"&gt;LOCK_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;8675309&lt;/span&gt;       &lt;span class="c1"&gt;# same constant in every replica
&lt;/span&gt;&lt;span class="n"&gt;BUDGET_SECONDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;     &lt;span class="c1"&gt;# hard stop for the whole migration step
&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CAP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;20.0&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_migration&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dsn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ddl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;psycopg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dsn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;autocommit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                           &lt;span class="n"&gt;application_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;migrator&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT pg_try_advisory_lock(%s)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LOCK_KEY&lt;/span&gt;&lt;span class="p"&gt;,))&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchone&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="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;another migrator holds the lock; nothing to do&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="mi"&gt;0&lt;/span&gt;

    &lt;span class="n"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;BUDGET_SECONDS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&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;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;with&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;transaction&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&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;cur&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SET LOCAL lock_timeout = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3s&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ddl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;applied on attempt &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="si"&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;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
            &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;psycopg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LockNotAvailable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="nf"&gt;log_blockers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&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="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CAP&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BASE&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempt&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;if&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;deadline&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lock budget exhausted; failing the deploy&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="mi"&gt;1&lt;/span&gt;
                &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;blocked; retry &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; in &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;time&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;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT pg_advisory_unlock(%s)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;LOCK_KEY&lt;/span&gt;&lt;span class="p"&gt;,))&lt;/span&gt;
        &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note that only &lt;code&gt;LockNotAvailable&lt;/code&gt; (SQLSTATE 55P03) is retried. A syntax error or a constraint violation is not a transient condition, and retrying it just burns the budget.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: retry only the lock-timeout error, sleep a random interval rather than a computed one, and log the blocker before every sleep.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When should the deploy just fail?
&lt;/h2&gt;

&lt;p&gt;When the budget runs out. A migration that has been blocked for five minutes is not waiting on a slow query; it's waiting on something structural — an idle-in-transaction connection from a pool with no &lt;code&gt;idle_in_transaction_session_timeout&lt;/code&gt;, a BI tool holding a long read, a &lt;code&gt;pg_dump&lt;/code&gt; that overlaps your deploy window. Retrying past that point hides the diagnosis you actually need.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom in the retry log&lt;/th&gt;
&lt;th&gt;Likely cause&lt;/th&gt;
&lt;th&gt;Fix, not a retry&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Blocker &lt;code&gt;state = idle in transaction&lt;/code&gt;, age growing&lt;/td&gt;
&lt;td&gt;App or pool leaking an open transaction&lt;/td&gt;
&lt;td&gt;Set &lt;code&gt;idle_in_transaction_session_timeout&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blocker is a long analytics &lt;code&gt;SELECT&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Reporting traffic on the primary&lt;/td&gt;
&lt;td&gt;Move reads to a replica, or migrate off-peak&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blocker is another &lt;code&gt;migrator:*&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Advisory lock missing or disabled&lt;/td&gt;
&lt;td&gt;Fix serialization first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No blocker found, still timing out&lt;/td&gt;
&lt;td&gt;Blocker is short but constant&lt;/td&gt;
&lt;td&gt;Raise &lt;code&gt;lock_timeout&lt;/code&gt; slightly, or use a quieter window&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: the budget's job is to convert an invisible hang into a red deploy with the blocker's identity attached.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Why does my Postgres migration fail with "canceling statement due to lock timeout"?&lt;/strong&gt;&lt;br&gt;
Because &lt;code&gt;lock_timeout&lt;/code&gt; is doing its job: your DDL waited longer than that limit for a lock another session held. That error means your app stayed up. Look at what held the lock — usually an idle-in-transaction connection or a long-running read — rather than raising the timeout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can two application replicas run database migrations at the same time?&lt;/strong&gt;&lt;br&gt;
Yes, unless something stops them. Serialize the runner with &lt;code&gt;pg_try_advisory_lock&lt;/code&gt; on a fixed key, or confirm your migration tool takes its own lock. Behind PgBouncer in transaction pooling mode, use &lt;code&gt;pg_advisory_xact_lock&lt;/code&gt; instead, since session-level locks outlive the transaction that took them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should migration retries use exponential backoff?&lt;/strong&gt;&lt;br&gt;
Use exponential backoff with full jitter — a random sleep between zero and the current cap. Deterministic delays make every replica wake at the same instant and contend again. Cap total retry time with a wall-clock budget and fail the deploy when it's exhausted.&lt;/p&gt;

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

&lt;p&gt;If your migrations already use &lt;code&gt;lock_timeout&lt;/code&gt; and a non-blocking two-step for rewrites, the missing piece is the runner around them. Add one migrator via advisory lock (or verify your tool has one), scope the timeout with &lt;code&gt;SET LOCAL&lt;/code&gt;, tag the session with a distinctive &lt;code&gt;application_name&lt;/code&gt;, log blockers before each jittered retry, and stop hard at a fixed budget. Teams on Flyway, Liquibase, Rails, or golang-migrate mostly need to verify the lock is on and add the budget; teams on Alembic or a hand-rolled script need the whole contract. The point isn't to make blocked migrations succeed — it's to make them fail fast, once, with the blocker named.&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/admission-control-for-self-hosted-llms-rejecting-requests-before-the-kv-cache-ooms-you-35b8"&gt;Admission Control for Self-Hosted LLMs: Rejecting Requests Before the KV Cache OOMs You&lt;/a&gt;&lt;/li&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;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>devops</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Your Postgres Backups Are Untested Until You Restore One: A Drill for Small Teams</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Mon, 17 Aug 2026 15:20:20 +0000</pubDate>
      <link>https://dev.to/libme/your-postgres-backups-are-untested-until-you-restore-one-a-drill-for-small-teams-3paj</link>
      <guid>https://dev.to/libme/your-postgres-backups-are-untested-until-you-restore-one-a-drill-for-small-teams-3paj</guid>
      <description>&lt;p&gt;A backup job that exits 0 tells you a file was written. It does not tell you the file can become a running database with your schema, your extensions, and your roles intact. The only way to know is to restore it on a schedule and time yourself, and the first restore you ever attempt should not be during an outage.&lt;/p&gt;

&lt;p&gt;This is the drill I run — roughly monthly, and always after any change to the schema, the extension list, or the Postgres major version. It takes about twenty minutes once it's scripted, and every single time I've introduced it somewhere, the first run failed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does a backup that "succeeded" fail to restore?
&lt;/h2&gt;

&lt;p&gt;Because &lt;code&gt;pg_dump&lt;/code&gt; captures the contents of one database, not the environment it lived in. The three things it leaves behind are the three things that break your restore:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Roles are cluster-level, not database-level.&lt;/strong&gt; &lt;code&gt;pg_dump&lt;/code&gt; does not include &lt;code&gt;CREATE ROLE&lt;/code&gt;. Restore into a fresh cluster and 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;pg_restore: error: could not execute query: ERROR:  role "app_user" does not exist
Command was: ALTER TABLE public.orders OWNER TO app_user;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Extensions must exist as installed binaries on the target machine.&lt;/strong&gt; The dump contains &lt;code&gt;CREATE EXTENSION vector;&lt;/code&gt;, but that only works if the target already has the pgvector shared library on disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR:  could not open extension control file "/usr/share/postgresql/16/extension/vector.control": No such file or directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the one that bites people who dump from a managed provider with extensions preinstalled and restore into a stock &lt;code&gt;postgres:16&lt;/code&gt; container.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Client and server versions have to line up.&lt;/strong&gt; &lt;code&gt;pg_dump&lt;/code&gt; refuses to dump from a server newer than itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pg_dump: error: server version: 16.3; pg_dump version: 15.6
pg_dump: error: aborting because of server version mismatch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The failure mode that actually hurts is silent: your cron box quietly ships an older &lt;code&gt;pg_dump&lt;/code&gt; than your upgraded server, the job starts failing, and nobody reads the log because nothing pages on a backup job. Alert on backup &lt;em&gt;failure and staleness&lt;/em&gt;, not just on failure.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A backup job's exit code proves a file exists; only a restore proves the file is a database.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;Five steps, all scriptable, all runnable on a laptop.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pull the most recent backup artifact — from object storage, not from a copy sitting on the same host as production.&lt;/li&gt;
&lt;li&gt;Start a throwaway Postgres of the &lt;strong&gt;same major version&lt;/strong&gt; as production.&lt;/li&gt;
&lt;li&gt;Restore into it with errors treated as fatal.&lt;/li&gt;
&lt;li&gt;Run assertions: row counts on your three or four most important tables, plus a query that exercises an extension.&lt;/li&gt;
&lt;li&gt;Record the wall-clock time and tear it down.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's the core of it. Taking the dump:&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;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

pg_dump &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;custom &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-owner&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-privileges&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"backup_&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%Y%m%dT%H%M%SZ&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.dump"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DATABASE_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--no-owner&lt;/code&gt; and &lt;code&gt;--no-privileges&lt;/code&gt; are what make the dump portable: ownership statements get dropped, so the restore doesn't demand that &lt;code&gt;app_user&lt;/code&gt; exists on the target. You then re-apply grants from your migration tooling, where they belong. Keep roles in version control as SQL; do not rely on them surviving in a dump.&lt;/p&gt;

&lt;p&gt;The drill itself:&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;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;DUMP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;PGVER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PGVER&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;16&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;CONTAINER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"restore-drill-&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nv"&gt;start&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;

docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;drill &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 55432:5432 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"pgvector/pgvector:pg&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;PGVER&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;until &lt;/span&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; pg_isready &lt;span class="nt"&gt;-U&lt;/span&gt; postgres &lt;span class="nt"&gt;-q&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done

&lt;/span&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PGPASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;drill
&lt;span class="nv"&gt;CONN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"postgresql://postgres@localhost:55432/postgres"&lt;/span&gt;

pg_restore &lt;span class="nt"&gt;--dbname&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--exit-on-error&lt;/span&gt; &lt;span class="nt"&gt;--jobs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DUMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

psql &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;ON_ERROR_STOP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;-f&lt;/span&gt; drill_assertions.sql

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"restore completed in &lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; start &lt;span class="k"&gt;))&lt;/span&gt;&lt;span class="s2"&gt;s"&lt;/span&gt;
docker &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CONTAINER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two flags carry most of the weight. &lt;code&gt;--exit-on-error&lt;/code&gt; is the important one: &lt;strong&gt;by default &lt;code&gt;pg_restore&lt;/code&gt; prints errors, keeps going, and exits 0&lt;/strong&gt;, which means an unattended restore check without it will happily report success on a half-populated database. &lt;code&gt;--jobs=4&lt;/code&gt; parallelizes table data and index builds, and it only works with the custom or directory formats — another reason to stop using plain SQL dumps for anything large.&lt;/p&gt;

&lt;p&gt;The assertions file is deliberately boring, and it should fail loudly:&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="err"&gt;\&lt;/span&gt;&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;ON_ERROR_STOP&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt;

&lt;span class="c1"&gt;-- structural: does the extension work, not just exist?&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'[1,2,3]'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'[3,2,1]'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;distance_check&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- volumetric: catch a restore that "worked" but landed empty&lt;/span&gt;
&lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;DECLARE&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt;
    &lt;span class="n"&gt;RAISE&lt;/span&gt; &lt;span class="n"&gt;EXCEPTION&lt;/span&gt; &lt;span class="s1"&gt;'orders table has only % rows — restore is suspect'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- freshness: how much data would we actually have lost?&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;data_age&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last query is the one worth reading out loud in a team channel. It converts an abstract retention policy into a number: &lt;em&gt;if we restored right now, we would be missing this much.&lt;/em&gt; That number is your real RPO, and it is usually worse than whatever the backup docs implied.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Assert on row counts and data age, not on the restore's exit code — an empty database restores perfectly.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Nightly dumps or point-in-time recovery?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pg_dump&lt;/code&gt; gives you a consistent snapshot and nothing between snapshots. If it runs at 03:00 and you lose the primary at 17:00, you have lost fourteen hours. Point-in-time recovery closes that gap by shipping the write-ahead log continuously, so you can replay to a chosen moment — including "one second before that &lt;code&gt;DELETE&lt;/code&gt; without a &lt;code&gt;WHERE&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;Approach&lt;/th&gt;
&lt;th&gt;Typical RPO&lt;/th&gt;
&lt;th&gt;Restore complexity&lt;/th&gt;
&lt;th&gt;Where it fits&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pg_dump&lt;/code&gt; custom format to object storage&lt;/td&gt;
&lt;td&gt;Since last dump (hours)&lt;/td&gt;
&lt;td&gt;Low — one command&lt;/td&gt;
&lt;td&gt;Side projects, small internal apps, portable migrations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Managed provider snapshots + PITR&lt;/td&gt;
&lt;td&gt;Seconds to minutes&lt;/td&gt;
&lt;td&gt;Low, but provider-shaped&lt;/td&gt;
&lt;td&gt;Anything on RDS, Cloud SQL, Supabase, Neon, Crunchy Bridge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pgBackRest or WAL-G to your own bucket&lt;/td&gt;
&lt;td&gt;Seconds to minutes&lt;/td&gt;
&lt;td&gt;Medium — real config, real ops&lt;/td&gt;
&lt;td&gt;Self-hosted Postgres you intend to keep&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Physical replica only&lt;/td&gt;
&lt;td&gt;Near zero for hardware loss&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;Not a backup — replicates your &lt;code&gt;DROP TABLE&lt;/code&gt; faithfully&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row is the trap I see most often. A standby protects you from a dead machine; it does not protect you from a bad migration, because the destructive statement replicates in milliseconds. Keep both.&lt;/p&gt;

&lt;p&gt;If you self-host and want continuous archiving without writing your own WAL shipping, pgBackRest is the option that handles full/differential/incremental backups, parallel compression, and retention expiry with one config file and one command — at the cost of a genuinely non-trivial setup pass and a config format you will have to read the docs for every time you touch it. WAL-G is the leaner alternative when you want to push straight to S3-compatible storage with minimal moving parts, though you'll find fewer worked examples when something goes wrong. On the managed side, Neon's branching turns a restore drill into creating a branch from a past timestamp and pointing a test connection string at it, which is the lowest-friction version of this whole workflow — the constraint being that it's provider-specific, so you should still keep an independent logical dump if you ever want to leave.&lt;/p&gt;

&lt;p&gt;Whatever you pick, store backups in an account or bucket that your application's credentials cannot delete. Ransomware and a bad &lt;code&gt;terraform destroy&lt;/code&gt; fail the same way.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A replica is not a backup, and a backup you cannot restore without the original provider is a hostage.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How often should you run the drill?
&lt;/h2&gt;

&lt;p&gt;Monthly is a reasonable floor for a small team. Run it additionally after: a Postgres major version upgrade, adding or removing an extension, changing the backup tool or its flags, and any change to who owns the storage bucket. Wire it into CI on a schedule if you can — a weekly GitHub Actions job that restores yesterday's dump into a service container and runs the assertions gives you a red build instead of a discovery at 2 a.m.&lt;/p&gt;

&lt;p&gt;Track exactly two numbers over time: minutes to a usable database (your RTO) and the &lt;code&gt;data_age&lt;/code&gt; from the assertions (your RPO). If either number surprises someone on the team, you've found the actual gap. As of mid-2026 I've never seen a first drill where both numbers matched what people assumed.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;How do I test a Postgres backup without touching production?&lt;/strong&gt;&lt;br&gt;
Restore the dump into a disposable container on a non-production port, run assertions against it, then destroy the container. Nothing in the drill connects to production except the read that fetched the backup file from object storage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does pg_restore exit 0 even though it printed errors?&lt;/strong&gt;&lt;br&gt;
Because &lt;code&gt;pg_restore&lt;/code&gt; treats most errors as non-fatal by default so it can restore as much as possible. Pass &lt;code&gt;--exit-on-error&lt;/code&gt; (and &lt;code&gt;--single-transaction&lt;/code&gt; if you want all-or-nothing) whenever a script is checking the result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is a read replica enough of a backup for a small app?&lt;/strong&gt;&lt;br&gt;
No. A replica protects against losing a machine but faithfully replicates destructive SQL such as a bad &lt;code&gt;DELETE&lt;/code&gt; or migration. You need point-in-time recovery or periodic dumps to recover from a mistake you made yourself.&lt;/p&gt;

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

&lt;p&gt;If you run a side project or a small internal app, nightly &lt;code&gt;pg_dump --format=custom --no-owner&lt;/code&gt; to object storage plus a monthly scripted restore drill is enough, and it's an afternoon of work. Once real customer data is involved, move to continuous archiving — your provider's PITR if you're managed, pgBackRest or WAL-G if you're self-hosted — and keep the logical dump as your escape hatch from the provider. Never count a physical replica as a backup. And measure the drill: the restore you have never timed is the one that takes three hours on the worst possible day.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/instrument-a-nodejs-app-with-opentelemetry-in-an-afternoon-dll"&gt;Instrument a Node.js App with OpenTelemetry in an Afternoon&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/the-boring-stack-manifesto-why-your-startup-probably-doesnt-need-kubernetes-55bo"&gt;The Boring Stack Manifesto: Why Your Startup Probably Doesn't Need Kubernetes&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>devops</category>
      <category>backend</category>
    </item>
    <item>
      <title>Why Your Docker Build Takes 11 Minutes in CI When It Takes 20 Seconds Locally</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Sun, 16 Aug 2026 15:04:48 +0000</pubDate>
      <link>https://dev.to/libme/why-your-docker-build-takes-11-minutes-in-ci-when-it-takes-20-seconds-locally-3foe</link>
      <guid>https://dev.to/libme/why-your-docker-build-takes-11-minutes-in-ci-when-it-takes-20-seconds-locally-3foe</guid>
      <description>&lt;p&gt;If your Docker build is fast locally and slow in CI, the base image is almost never the problem. CI runners are ephemeral, so they start with an empty layer cache unless you explicitly wire one up, and a &lt;code&gt;COPY . .&lt;/code&gt; placed above your dependency install throws away whatever cache you did manage to restore. Fix the layer ordering first, then attach a cache backend, and measure with &lt;code&gt;--progress=plain&lt;/code&gt; so you can see which steps actually say &lt;code&gt;CACHED&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is the version of the problem I keep running into on small teams: the build was fine when it lived on one laptop, then it moved to a hosted runner and quietly became the longest step in the pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is the Docker cache empty on every CI run?
&lt;/h2&gt;

&lt;p&gt;Locally, your daemon keeps every intermediate layer on disk between builds. A hosted runner is a fresh VM. When the job starts, &lt;code&gt;docker build&lt;/code&gt; has nothing to compare against, so every &lt;code&gt;RUN&lt;/code&gt; re-executes from scratch — including the three-minute &lt;code&gt;npm ci&lt;/code&gt; or &lt;code&gt;pip install&lt;/code&gt; you never think about.&lt;/p&gt;

&lt;p&gt;You can confirm it in one run. Add plain progress output and read the log:&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;--progress&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;plain &lt;span class="nt"&gt;-t&lt;/span&gt; myapp:ci &lt;span class="nb"&gt;.&lt;/span&gt; 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;'CACHED|DONE'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a warm local build you'll see a wall of &lt;code&gt;CACHED&lt;/code&gt; lines. On a cold runner you'll see almost none. That difference — not the image size, not the base distro — is your eleven minutes.&lt;/p&gt;

&lt;p&gt;The related trap is that people "fix" this by switching from &lt;code&gt;node:22&lt;/code&gt; to &lt;code&gt;node:22-alpine&lt;/code&gt; and are confused when the build time barely moves. Image size affects push and pull time; cache hits affect build time. They're different bills.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: a slow CI build is a cache-miss problem until you have proven otherwise with &lt;code&gt;--progress=plain&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How should a Dockerfile be ordered so the cache actually holds?
&lt;/h2&gt;

&lt;p&gt;BuildKit invalidates a layer when its inputs change, and every layer after it. So the rule is: things that change rarely go up top, things that change on every commit go at the bottom. In practice that means copying your manifest and lockfile alone, installing, and only then copying source.&lt;/p&gt;

&lt;p&gt;Here's a Node example with the two common mistakes removed:&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="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:22-slim&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;deps&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;

&lt;span class="c"&gt;# Only the lockfile inputs — this layer survives ordinary code changes.&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; package.json package-lock.json ./&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;npm ci &lt;span class="nt"&gt;--omit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev

&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;node:22-slim&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s"&gt;runtime&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;ENV&lt;/span&gt;&lt;span class="s"&gt; NODE_ENV=production&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; --from=deps /app/node_modules ./node_modules&lt;/span&gt;
&lt;span class="c"&gt;# Source last: a code-only commit invalidates nothing above this line.&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; node&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["node", "server.js"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Python shape is identical — &lt;code&gt;COPY requirements.txt .&lt;/code&gt; and &lt;code&gt;RUN pip install -r requirements.txt&lt;/code&gt; before &lt;code&gt;COPY . .&lt;/code&gt;. If you use Poetry or uv, copy the manifest &lt;em&gt;and&lt;/em&gt; the lockfile together, because installing from a manifest without its lock defeats the point of a reproducible layer.&lt;/p&gt;

&lt;p&gt;Two details that bite people:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;.dockerignore&lt;/code&gt; that misses &lt;code&gt;.git&lt;/code&gt; or &lt;code&gt;node_modules&lt;/code&gt; means your &lt;code&gt;COPY . .&lt;/code&gt; context changes on every build for reasons unrelated to your code. Check it before blaming BuildKit.&lt;/li&gt;
&lt;li&gt;Anything that writes a timestamp or a build ID into an early layer will invalidate everything below it, forever. Push those to the last stage.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: if &lt;code&gt;COPY . .&lt;/code&gt; appears above your dependency install, no cache backend on earth will save that build.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you make the cache survive between CI runs?
&lt;/h2&gt;

&lt;p&gt;Ordering only pays off if there's something to restore from. BuildKit can export its cache to an external backend and import it on the next run. On GitHub Actions the least-effort option is the built-in cache backend:&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;build&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;]&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;image&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;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;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;docker/setup-buildx-action@v3&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;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;false&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;myapp:ci&lt;/span&gt;
          &lt;span class="na"&gt;cache-from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type=gha&lt;/span&gt;
          &lt;span class="na"&gt;cache-to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type=gha,mode=max&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mode=max&lt;/code&gt; exports intermediate stage layers too, not just the final image — which is what you want when you have a multi-stage build, since the expensive &lt;code&gt;npm ci&lt;/code&gt; lives in a stage that never ships. The tradeoff is a larger cache, and GitHub's Actions cache is capped per repository (10 GB as of mid-2026) with least-recently-used eviction, so a &lt;code&gt;mode=max&lt;/code&gt; cache on a busy monorepo can push your other caches out.&lt;/p&gt;

&lt;p&gt;The other option is a registry cache, which works on any CI provider and has no 10 GB ceiling:&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;cache-from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type=registry,ref=ghcr.io/you/myapp:buildcache&lt;/span&gt;
          &lt;span class="na"&gt;cache-to&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;type=registry,ref=ghcr.io/you/myapp:buildcache,mode=max&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want this without maintaining runner infrastructure, Depot is the managed builder that keeps a persistent BuildKit cache volume across runs so you skip the export/import round trip entirely.&lt;/p&gt;

&lt;p&gt;One version note: the &lt;code&gt;type=gha&lt;/code&gt; backend was rewritten to use GitHub's newer cache service, and older Buildx releases talked to an API that GitHub has since retired. If you pinned &lt;code&gt;setup-buildx-action&lt;/code&gt; or a Buildx version a couple of years ago and your cache silently stopped working, that's the first thing to check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: &lt;code&gt;cache-from&lt;/code&gt;/&lt;code&gt;cache-to&lt;/code&gt; is the line that turns a correct Dockerfile into a fast pipeline; without it the ordering work is invisible.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What about &lt;code&gt;RUN --mount=type=cache&lt;/code&gt;?
&lt;/h2&gt;

&lt;p&gt;Cache mounts are the other half, and they're the part that surprises people. They give a &lt;code&gt;RUN&lt;/code&gt; step a persistent directory for a package manager's own cache:&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;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;cache,target&lt;span class="o"&gt;=&lt;/span&gt;/root/.npm &lt;span class="se"&gt;\
&lt;/span&gt;    npm ci &lt;span class="nt"&gt;--omit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is genuinely good on a long-lived builder: even when the lockfile changes and the layer must rebuild, the package manager re-downloads almost nothing.&lt;/p&gt;

&lt;p&gt;The catch: cache mount contents are builder-local state. They are not part of the exported layer cache, so &lt;code&gt;cache-to: type=gha&lt;/code&gt; does not carry them to the next ephemeral runner. On hosted CI they mostly help within a single build, or when you run a self-hosted/persistent builder. Treat them as a bonus on top of correct layer ordering, not a substitute for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: cache mounts speed up rebuilds on a builder that sticks around; layer cache export is what helps a fresh runner.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Which fix applies to which symptom?
&lt;/h2&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;Likely cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Every step rebuilds, no &lt;code&gt;CACHED&lt;/code&gt; lines&lt;/td&gt;
&lt;td&gt;No cache backend on an ephemeral runner&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cache-from&lt;/code&gt;/&lt;code&gt;cache-to&lt;/code&gt; (gha or registry)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dependency install reruns on code-only commits&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;COPY . .&lt;/code&gt; above the install&lt;/td&gt;
&lt;td&gt;Copy manifest + lockfile first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache hits locally, misses in CI on the same commit&lt;/td&gt;
&lt;td&gt;Build context differs&lt;/td&gt;
&lt;td&gt;Fix &lt;code&gt;.dockerignore&lt;/code&gt;, check generated files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build is fast, deploy is slow&lt;/td&gt;
&lt;td&gt;Image size, not cache&lt;/td&gt;
&lt;td&gt;Multi-stage, slim base, drop build tools&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache worked, then stopped after months&lt;/td&gt;
&lt;td&gt;Cache evicted or backend API changed&lt;/td&gt;
&lt;td&gt;Check size limits; update Buildx/actions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: match the fix to the symptom you measured, because "slow build" and "slow deploy" have almost no overlap in causes.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Why is my Docker build slow in GitHub Actions but fast locally?&lt;/strong&gt;&lt;br&gt;
Because the hosted runner is a fresh machine with no layer cache. Local builds reuse layers your daemon kept on disk; CI has nothing to reuse until you configure &lt;code&gt;cache-from&lt;/code&gt;/&lt;code&gt;cache-to&lt;/code&gt; with a backend like &lt;code&gt;type=gha&lt;/code&gt; or &lt;code&gt;type=registry&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does &lt;code&gt;docker build --no-cache&lt;/code&gt; explain the difference?&lt;/strong&gt;&lt;br&gt;
Only if it's actually in your workflow — check for it, since people add it to debug a stale build and forget to remove it. Otherwise the empty cache is structural to the runner, not a flag.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will a smaller base image make my build faster?&lt;/strong&gt;&lt;br&gt;
Rarely. A slim or Alpine base makes the image faster to push and pull, but build time is dominated by cache misses on install steps. Fix ordering and cache export first, then optimize size for deploy speed.&lt;/p&gt;

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

&lt;p&gt;Start by reading &lt;code&gt;--progress=plain&lt;/code&gt; output on a CI run and counting &lt;code&gt;CACHED&lt;/code&gt; lines — that tells you whether you have a cache problem or a size problem. If you have a cache problem, reorder the Dockerfile so lockfiles land above source, then add a cache backend: &lt;code&gt;type=gha&lt;/code&gt; if you live on GitHub Actions and your cache fits under the repo limit, &lt;code&gt;type=registry&lt;/code&gt; if you're on another provider or you keep getting evicted. Add cache mounts only after those two are in place, and only expect them to pay off on a persistent builder. Most of the eleven minutes goes away at step two.&lt;/p&gt;

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

&lt;ul&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/the-real-break-even-for-ai-coding-tools-includes-review-time-not-just-typing-saved-48nb"&gt;The Real Break-Even for AI Coding Tools Includes Review Time, Not Just Typing Saved&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/your-password-reset-emails-are-going-to-spam-choosing-between-resend-postmark-and-amazon-ses-3jak"&gt;Your Password Reset Emails Are Going to Spam: Choosing Between Resend, Postmark, and Amazon SES&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>docker</category>
      <category>cicd</category>
      <category>devops</category>
      <category>github</category>
    </item>
    <item>
      <title>Getting 429 Too Many Requests? Fix the Client Before You Ask for a Higher Quota</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Sun, 16 Aug 2026 00:25:01 +0000</pubDate>
      <link>https://dev.to/libme/getting-429-too-many-requests-fix-the-client-before-you-ask-for-a-higher-quota-7c1</link>
      <guid>https://dev.to/libme/getting-429-too-many-requests-fix-the-client-before-you-ask-for-a-higher-quota-7c1</guid>
      <description>&lt;p&gt;If your integration is throwing &lt;code&gt;429 Too Many Requests&lt;/code&gt;, adding a retry loop usually makes it worse: every retry is another request against the same quota, and a fleet of workers all retrying at the same interval turns a small overage into a synchronized stampede. The fix is to stop sending too fast in the first place — a token bucket in front of the client — and to treat the server's &lt;code&gt;Retry-After&lt;/code&gt; header as the authority whenever you do get throttled. Asking the vendor for a quota increase should be the last step, not the first.&lt;/p&gt;

&lt;p&gt;This is the thing I get wrong most often when I move fast: I wire up an API, hit the limit under real traffic, wrap the call in &lt;code&gt;try/except&lt;/code&gt; with a &lt;code&gt;sleep(1)&lt;/code&gt;, and ship it. It works in staging with one worker and falls apart the moment two processes run concurrently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does adding retries make 429s worse?
&lt;/h2&gt;

&lt;p&gt;Three failure modes, in the order I usually hit them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retries spend quota.&lt;/strong&gt; A naive retry loop on a rate-limited endpoint sends attempt after attempt into a bucket that is already empty. If the limiter counts rejected requests (many do — the rejection still costs the gateway work), your retries actively delay recovery.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fixed sleeps synchronize.&lt;/strong&gt; Every worker that hits the limit at 12:00:00 and sleeps exactly one second wakes at 12:00:01 together. You've built a metronome. This is the classic thundering herd, and it's why jitter isn't optional decoration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retrying non-idempotent calls duplicates work.&lt;/strong&gt; A &lt;code&gt;POST /charges&lt;/code&gt; that returns 429 might have been rejected before any work happened — or it might have been rejected by a downstream limiter after the charge was created. Without an idempotency key, a retry is a coin flip on double-charging someone.&lt;/p&gt;

&lt;p&gt;The takeaway: retries are the recovery path, not the rate control; if retries are your only rate control, you don't have any.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should the client actually do with &lt;code&gt;Retry-After&lt;/code&gt;?
&lt;/h2&gt;

&lt;p&gt;Read it, and parse both forms. &lt;code&gt;Retry-After&lt;/code&gt; is allowed to be either a delay in seconds or an HTTP-date, and I have been bitten by a client that assumed seconds, got a date string, and threw a &lt;code&gt;ValueError&lt;/code&gt; inside its own error handler.&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;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;email.utils&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;parsedate_to_datetime&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;retry_after_seconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;header&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="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fallback&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Parse Retry-After (delay-seconds OR HTTP-date). Falls back on garbage.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;header&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;fallback&lt;/span&gt;
    &lt;span class="n"&gt;header&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&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="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;pass&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;when&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parsedate_to_datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;header&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fallback&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;when&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tzinfo&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;when&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;when&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tzinfo&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&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;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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;when&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;total_seconds&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clamp the result before you use it. A server that is having a bad day can return &lt;code&gt;Retry-After: 3600&lt;/code&gt;, and a worker that blindly sleeps for an hour looks exactly like a hung process to whatever is watching it. I cap at something like 60 seconds and let the job fail into a retry queue beyond that.&lt;/p&gt;

&lt;p&gt;Beyond &lt;code&gt;Retry-After&lt;/code&gt;, what you get is vendor-specific. Many APIs expose &lt;code&gt;X-RateLimit-Limit&lt;/code&gt;, &lt;code&gt;X-RateLimit-Remaining&lt;/code&gt;, and &lt;code&gt;X-RateLimit-Reset&lt;/code&gt;; the IETF has been working on standardizing &lt;code&gt;RateLimit-Limit&lt;/code&gt; / &lt;code&gt;RateLimit-Remaining&lt;/code&gt; / &lt;code&gt;RateLimit-Reset&lt;/code&gt;, but as of mid-2026 that is still a draft, so read the docs for the specific API rather than assuming the header names. When &lt;code&gt;Remaining&lt;/code&gt; is exposed, feeding it back into your limiter is the single cheapest improvement you can make — you stop guessing at the budget and start reading it.&lt;/p&gt;

&lt;p&gt;The takeaway: treat &lt;code&gt;Retry-After&lt;/code&gt; as authoritative but bounded, and treat every other rate-limit header as vendor-specific until you've read the docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I limit the client side without over-engineering it?
&lt;/h2&gt;

&lt;p&gt;A token bucket is about twenty lines and covers most cases. It allows a burst up to the bucket capacity, then settles into a steady rate — which is what most APIs actually enforce.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TokenBucket&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;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rate_per_sec&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;burst&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rate_per_sec&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;capacity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;burst&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;burst&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;updated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_lock&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="nc"&gt;Lock&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;acquire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_lock&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;capacity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;updated&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rate&lt;/span&gt;
                &lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;updated&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;tokens&lt;/span&gt;
                    &lt;span class="k"&gt;return&lt;/span&gt;
                &lt;span class="n"&gt;wait&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rate&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;wait&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use &lt;code&gt;time.monotonic()&lt;/code&gt;, not &lt;code&gt;time.time()&lt;/code&gt;. A clock adjustment (NTP step, container migration) can move wall-clock time backwards, and a limiter that computes a negative elapsed interval will either stall or hand out free tokens.&lt;/p&gt;

&lt;p&gt;One honest limitation: this implementation is not FIFO. Waiters wake up and re-race for the lock, so under heavy contention a request can wait longer than others that arrived after it. For a background worker that's fine. If you need fairness guarantees for user-facing latency, hand out ordered tickets instead of re-looping.&lt;/p&gt;

&lt;p&gt;Wiring it together with retries, jitter, and a bounded sleep:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;

&lt;span class="n"&gt;RETRYABLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;502&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;504&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;request_with_retry&lt;/span&gt;&lt;span class="p"&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;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;TokenBucket&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;method&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;url&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;*&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;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&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;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_attempts&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;await&lt;/span&gt; &lt;span class="n"&gt;bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;acquire&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="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&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;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;RETRYABLE&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;
        &lt;span class="n"&gt;backoff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;60.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempt&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;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;60.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;retry_after_seconds&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;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Retry-After&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;backoff&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="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uniform&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="mf"&gt;0.3&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&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="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;AssertionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unreachable&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only retry &lt;code&gt;POST&lt;/code&gt;/&lt;code&gt;PATCH&lt;/code&gt; through this path if the request carries an idempotency key the server honors. Otherwise restrict retries to &lt;code&gt;GET&lt;/code&gt;/&lt;code&gt;PUT&lt;/code&gt;/&lt;code&gt;DELETE&lt;/code&gt; and let write failures surface.&lt;/p&gt;

&lt;p&gt;The takeaway: a token bucket plus exponential backoff with jitter, bounded by a hard ceiling, handles the overwhelming majority of third-party API throttling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which limiting strategy fits which problem?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Strategy&lt;/th&gt;
&lt;th&gt;What it controls&lt;/th&gt;
&lt;th&gt;Burst behavior&lt;/th&gt;
&lt;th&gt;Needs shared state&lt;/th&gt;
&lt;th&gt;Use it when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fixed window counter&lt;/td&gt;
&lt;td&gt;Requests per calendar window&lt;/td&gt;
&lt;td&gt;Allows 2× at window boundaries&lt;/td&gt;
&lt;td&gt;Yes (if multi-process)&lt;/td&gt;
&lt;td&gt;Simplest server-side enforcement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sliding window log&lt;/td&gt;
&lt;td&gt;Requests over a rolling window&lt;/td&gt;
&lt;td&gt;Accurate, no boundary spike&lt;/td&gt;
&lt;td&gt;Yes, and it's memory-hungry&lt;/td&gt;
&lt;td&gt;You must match a strict rolling quota&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token bucket&lt;/td&gt;
&lt;td&gt;Sustained rate + burst size&lt;/td&gt;
&lt;td&gt;Burst up to capacity, then steady&lt;/td&gt;
&lt;td&gt;Only for multi-process&lt;/td&gt;
&lt;td&gt;Client-side pacing of a third-party API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Concurrency semaphore&lt;/td&gt;
&lt;td&gt;In-flight requests&lt;/td&gt;
&lt;td&gt;Unbounded rate, bounded parallelism&lt;/td&gt;
&lt;td&gt;Per-process usually fine&lt;/td&gt;
&lt;td&gt;The limit is really about connections or memory&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The distinction people miss: a concurrency limit and a rate limit are different constraints. Ten concurrent requests that each take 50ms is roughly 200 requests/second; ten concurrent requests that each take 5 seconds is 2 requests/second. If the API's limit is expressed in requests per second, a semaphore will not save you.&lt;/p&gt;

&lt;p&gt;The takeaway: pick the strategy that matches how the limit is &lt;em&gt;expressed&lt;/em&gt;, not the one that's easiest to implement.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changes when you run more than one worker?
&lt;/h2&gt;

&lt;p&gt;An in-process bucket controls one process. Run four replicas and you send four times your intended rate. Once you're multi-process, the counter has to live somewhere shared, and the usual answer is Redis with a small Lua script so the check-and-increment is atomic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- KEYS[1] = bucket key, ARGV[1] = limit, ARGV[2] = window in ms&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'INCR'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'PEXPIRE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&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="n"&gt;ARGV&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="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="k"&gt;return&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;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'PTTL'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])}&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;return&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a fixed window, so it permits up to 2× the limit across a window boundary — acceptable when your client-side target is set below the real quota, not when you're pacing right at the ceiling. If you need a shared limiter from serverless functions where holding a TCP connection is awkward, Upstash Redis is the one that speaks HTTP and includes a rate-limiting helper, at the cost of per-request latency you wouldn't pay with a co-located Redis. If the traffic you need to shape is inbound rather than outbound, push the limit to the edge instead: Kong and Envoy both enforce rate limits at the proxy layer, which keeps the logic out of every service that sits behind them.&lt;/p&gt;

&lt;p&gt;The takeaway: the moment you scale past one process, your rate limiter needs shared state or it silently stops being a limit.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;What does HTTP 429 Too Many Requests mean?&lt;/strong&gt;&lt;br&gt;
It means the server accepted your request as well-formed and authenticated, but you exceeded a rate limit, so it refused to process it. It is a client-side pacing problem, not an error in the request itself, and the response often carries a &lt;code&gt;Retry-After&lt;/code&gt; header telling you how long to wait.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I automatically retry after a 429?&lt;/strong&gt;&lt;br&gt;
Yes, but only with backoff plus jitter, only up to a small number of attempts, and only for requests that are safe to repeat. Retrying a non-idempotent write without an idempotency key risks duplicating the operation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why am I still getting 429s after adding a rate limiter?&lt;/strong&gt;&lt;br&gt;
Almost always because the limiter is per-process and you're running multiple workers, or because the limit is enforced per-endpoint or per-resource rather than globally. Check whether your effective rate is your configured rate multiplied by your replica count.&lt;/p&gt;

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

&lt;p&gt;If you're seeing 429s, put a token bucket in front of the client and set it a comfortable margin below the documented quota before you touch anything else. Honor &lt;code&gt;Retry-After&lt;/code&gt; when it's present, clamp it so a bad value can't hang a worker, and add jitter so your fleet doesn't retry in lockstep. Move the counter into Redis the moment you run more than one replica, and push enforcement to a proxy like Kong or Envoy if you're limiting inbound traffic across many services. Ask for a quota increase only after your own numbers show you're pacing correctly and still hitting the ceiling.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/how-to-test-search-relevance-before-you-ship-a-ranking-change-29o"&gt;How to Test Search Relevance Before You Ship a Ranking Change&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/should-you-migrate-off-pgvector-run-this-shadow-mode-benchmark-first-3o54"&gt;Should You Migrate Off pgvector? Run This Shadow-Mode Benchmark First&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>api</category>
      <category>backend</category>
      <category>python</category>
      <category>performance</category>
    </item>
    <item>
      <title>Your Password Reset Emails Are Going to Spam: Choosing Between Resend, Postmark, and Amazon SES</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Sat, 15 Aug 2026 23:08:50 +0000</pubDate>
      <link>https://dev.to/libme/your-password-reset-emails-are-going-to-spam-choosing-between-resend-postmark-and-amazon-ses-3jak</link>
      <guid>https://dev.to/libme/your-password-reset-emails-are-going-to-spam-choosing-between-resend-postmark-and-amazon-ses-3jak</guid>
      <description>&lt;p&gt;If your transactional email is landing in spam, the provider is almost never the first thing to fix — misaligned SPF/DKIM/DMARC records and a shared sending domain are. Once DNS is correct, the choice comes down to what you want to operate: Amazon SES is the cheapest per message and the most work, Postmark buys you strict stream separation and deliverability support, and Resend buys you the fastest path from &lt;code&gt;npm install&lt;/code&gt; to a sent email. Below is the failure mode, the DNS fix, and where each provider actually breaks down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does "delivered" in the dashboard still mean spam folder?
&lt;/h2&gt;

&lt;p&gt;This is the part that wastes an afternoon. Every provider's dashboard reports a message as &lt;em&gt;Delivered&lt;/em&gt; when the receiving mail server returned &lt;code&gt;250 OK&lt;/code&gt; at the end of the SMTP transaction. That acknowledgement means Gmail accepted the message for processing. It says nothing about which folder Gmail then put it in — inbox, Promotions, or Spam. No sending API can see that, because the receiving side never tells you.&lt;/p&gt;

&lt;p&gt;So the symptom looks like this: your logs are clean, the provider shows 100% delivery, and users keep saying the reset link never arrived. The next dead end most people try is rewriting the email copy — removing the word "free", shortening the subject line, stripping images. That occasionally helps at the margin, but it is downstream of the real problem, which is that the receiving domain cannot verify you are who your &lt;code&gt;From&lt;/code&gt; header claims.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A &lt;code&gt;250 OK&lt;/code&gt; is proof of acceptance, not proof of inbox placement — treat provider "delivered" counts as a floor, not a result.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What DNS records do you actually need before blaming the provider?
&lt;/h2&gt;

&lt;p&gt;Three records, and the one that trips people up is alignment rather than existence. SPF and DKIM can both pass while DMARC still fails, because DMARC requires the passing mechanism's domain to &lt;em&gt;align&lt;/em&gt; with the domain in the visible &lt;code&gt;From&lt;/code&gt; header. If you send as &lt;code&gt;noreply@example.com&lt;/code&gt; but your SPF-authorized envelope sender is your provider's bounce domain and DKIM signs with a subdomain the provider owns, alignment fails and DMARC fails with it.&lt;/p&gt;

&lt;p&gt;Check what you have published before changing anything:&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;# SPF — must exist exactly once on the sending domain&lt;/span&gt;
dig +short TXT mail.example.com | &lt;span class="nb"&gt;grep &lt;/span&gt;spf1

&lt;span class="c"&gt;# DKIM — key selector varies per provider; check the one they gave you&lt;/span&gt;
dig +short TXT resend._domainkey.mail.example.com

&lt;span class="c"&gt;# DMARC — always at _dmarc.&amp;lt;domain&amp;gt;&lt;/span&gt;
dig +short TXT _dmarc.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A workable starting DMARC record is &lt;code&gt;v=DMARC1; p=none; rua=mailto:dmarc@example.com&lt;/code&gt; — report-only, so you can read aggregate reports for a week and confirm alignment before moving to &lt;code&gt;p=quarantine&lt;/code&gt;. Publishing &lt;code&gt;p=reject&lt;/code&gt; first is how people silently kill their own mail.&lt;/p&gt;

&lt;p&gt;Two more things that are non-negotiable as of mid-2026: since Google and Yahoo tightened their bulk sender requirements in February 2024, senders at volume need SPF, DKIM, &lt;em&gt;and&lt;/em&gt; DMARC, plus one-click unsubscribe on marketing mail and a spam complaint rate held well below their published threshold. And transactional mail should leave from a dedicated subdomain (&lt;code&gt;mail.example.com&lt;/code&gt;), so a bad marketing campaign cannot poison the reputation that carries your password resets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix alignment and subdomain separation first; switching providers to solve a DNS problem just moves the problem.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do Resend, Postmark, and SES actually differ?
&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;Amazon SES&lt;/th&gt;
&lt;th&gt;Postmark&lt;/th&gt;
&lt;th&gt;Resend&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pricing model&lt;/td&gt;
&lt;td&gt;Per thousand messages, cheapest at volume; data transfer and attachment size billed separately&lt;/td&gt;
&lt;td&gt;Per message, tiered by monthly volume; premium relative to SES&lt;/td&gt;
&lt;td&gt;Per message with a free developer tier, tiered plans above it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup friction&lt;/td&gt;
&lt;td&gt;Highest — sandbox by default, production access is a request form&lt;/td&gt;
&lt;td&gt;Low — domain verification, then send&lt;/td&gt;
&lt;td&gt;Lowest — API key and a verified domain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bounce/complaint handling&lt;/td&gt;
&lt;td&gt;You wire SNS or EventBridge to a configuration set and process events yourself&lt;/td&gt;
&lt;td&gt;Built-in suppression plus a searchable activity view&lt;/td&gt;
&lt;td&gt;Webhook events for delivery, bounce, and complaint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stream separation&lt;/td&gt;
&lt;td&gt;Configuration sets, self-managed&lt;/td&gt;
&lt;td&gt;First-class: transactional and broadcast streams are enforced&lt;/td&gt;
&lt;td&gt;Supported via separate domains/audiences, less opinionated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deliverability support&lt;/td&gt;
&lt;td&gt;AWS Support ticket, general-purpose&lt;/td&gt;
&lt;td&gt;Deliverability-specialist support is the product&lt;/td&gt;
&lt;td&gt;Younger track record; support scales with plan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best when&lt;/td&gt;
&lt;td&gt;You have volume and infra people&lt;/td&gt;
&lt;td&gt;Reset/receipt mail must not fail&lt;/td&gt;
&lt;td&gt;You want to ship this afternoon&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The three are genuinely different products that happen to share an API shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Amazon SES v2 — cheap, explicit, and you own the event plumbing&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;SESv2Client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SendEmailCommand&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="s2"&gt;@aws-sdk/client-sesv2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ses&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SESv2Client&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;us-east-1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;ses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;SendEmailCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;FromEmailAddress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Acme &amp;lt;noreply@mail.example.com&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;Destination&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ToAddresses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;ConfigurationSetName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;transactional&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// routes bounce/complaint events&lt;/span&gt;
  &lt;span class="na"&gt;Content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;Simple&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;Subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Reset your password&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;resetBody&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Postmark — message streams are a required argument, not an afterthought&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;ServerClient&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="s2"&gt;postmark&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;postmark&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ServerClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;POSTMARK_TOKEN&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;postmark&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendEmail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;From&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Acme &amp;lt;noreply@mail.example.com&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;To&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;Subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Reset your password&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;TextBody&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;resetBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;MessageStream&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;outbound&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// 'broadcast' mail must use a different stream&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Resend — the least ceremony between you and a sent message&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;Resend&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="s2"&gt;resend&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resend&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Resend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;RESEND_API_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;resend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;emails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Acme &amp;lt;noreply@mail.example.com&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Reset your password&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;resetBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The API call is the easy part in all three; what differs is who operates bounce handling, reputation, and stream hygiene — you, or the vendor.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When is SES the wrong default despite the price?
&lt;/h2&gt;

&lt;p&gt;SES is unbeatable on unit cost and it is the correct answer for high-volume senders who already run AWS. The trap is that new accounts start in a sandbox: you can only send to verified addresses, under a low daily quota (200 messages per 24 hours and one message per second at the time of writing), until you file for production access and get approved. Discovering that on launch day is a genuinely bad afternoon, and the approval is not instant.&lt;/p&gt;

&lt;p&gt;The second cost is operational. SES gives you an account-level suppression list and event streams, but the loop that turns a hard bounce into "stop emailing this address and mark it invalid in our users table" is yours to build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// SNS -&amp;gt; your endpoint. Hard bounces and complaints must reach your own suppression table.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleSesEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;Message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eventType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Bounce&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bounce&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bounceType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Permanent&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bounce&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bouncedRecipients&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="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;suppress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;emailAddress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hard_bounce&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eventType&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Complaint&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;complaint&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;complainedRecipients&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="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;suppress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;emailAddress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;complaint&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;If you want managed sending where bounce suppression, stream separation, and a human deliverability team come with the bill instead of the backlog, Postmark is the one that treats transactional reputation as the product rather than a feature. Its honest drawback: it is priced well above SES per message, it deliberately does not want to be your bulk marketing platform, and message content is retained only for a limited window, so your own logs still have to be the system of record.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SES saves money per message and spends it back in engineering hours — budget the bounce pipeline as part of the decision, not after it.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does Resend fit for a small team?
&lt;/h2&gt;

&lt;p&gt;Resend's pitch is developer experience, and it delivers: domain verification is quick, the SDKs are clean, and templating with React Email removes the worst part of HTML email if you already write React. For a solo developer or an early team, it is the shortest distance from zero to a correctly authenticated sending domain.&lt;/p&gt;

&lt;p&gt;The honest caveat is track record. Deliverability at shared-IP providers is partly a function of how aggressively the vendor polices its other customers, and that reputation compounds over years. Resend is the newest of the three, so if you are sending high-stakes mail at meaningful volume, plan to monitor Google Postmaster Tools yourself rather than assuming the platform reputation carries you. If your priority is getting authenticated transactional mail out this week with the least configuration, Resend is the one that gets you there fastest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick Resend for speed of setup, but instrument your own deliverability monitoring regardless of provider.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you migrate without burning your domain?
&lt;/h2&gt;

&lt;p&gt;Do not repoint an established sending domain at a new provider all at once. Verify the new provider on a fresh subdomain, send a low-stakes category of mail through it first (receipts, not password resets), watch complaint and bounce rates for a couple of weeks, then move the rest. If you are moving to a dedicated IP, warming is mandatory — a cold IP suddenly emitting thousands of messages looks exactly like a compromised host to every receiver, because usually it is one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Migrate one mail category at a time on a new subdomain; a big-bang cutover risks the one email your product cannot afford to lose.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Why do my emails go to spam even though SPF and DKIM pass?&lt;/strong&gt;&lt;br&gt;
Because DMARC requires alignment, not just passing checks: the domain that passes SPF or DKIM must match the domain in the visible &lt;code&gt;From&lt;/code&gt; header. Publish a &lt;code&gt;p=none&lt;/code&gt; DMARC record with a &lt;code&gt;rua&lt;/code&gt; address and read the aggregate reports — they will name the mechanism that is failing alignment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is Amazon SES cheaper than Postmark or Resend?&lt;/strong&gt;&lt;br&gt;
Per message, yes, and by a wide margin at volume. The cost moves into engineering: SES starts in a sandbox that requires an approval request, and you build bounce, complaint, and suppression handling yourself from SNS or EventBridge events.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should transactional and marketing email use the same domain?&lt;/strong&gt;&lt;br&gt;
No. Send transactional mail from a dedicated subdomain and marketing from a different one, so a campaign that draws spam complaints cannot damage the reputation delivering your password resets.&lt;/p&gt;

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

&lt;p&gt;If you are on AWS, sending at volume, and have someone who will own the bounce pipeline, SES is the right economics. If password resets and receipts are business-critical and you would rather pay than operate, Postmark is worth the premium for enforced stream separation and deliverability support. If you are a small team optimizing for time-to-first-correct-send, start with Resend and add your own monitoring. Whichever you pick, fix DNS alignment and split transactional onto its own subdomain first — that single change fixes more spam-folder problems 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/free-tier-traps-how-hobby-plans-are-designed-and-when-they-flip-to-paid-450e"&gt;Free Tier Traps: How Hobby Plans Are Designed and When They Flip to Paid&lt;/a&gt;&lt;/li&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/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>api</category>
      <category>aws</category>
      <category>backend</category>
      <category>saas</category>
    </item>
    <item>
      <title>Postgres Says "Too Many Clients Already": Diagnose It Before You Add a Pooler</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Sat, 15 Aug 2026 14:10:10 +0000</pubDate>
      <link>https://dev.to/libme/postgres-says-too-many-clients-already-diagnose-it-before-you-add-a-pooler-5dbb</link>
      <guid>https://dev.to/libme/postgres-says-too-many-clients-already-diagnose-it-before-you-add-a-pooler-5dbb</guid>
      <description>&lt;p&gt;&lt;code&gt;FATAL: sorry, too many clients already&lt;/code&gt; almost never means your database is out of capacity. It means something in your stack opened more connections than &lt;code&gt;max_connections&lt;/code&gt; allows — usually a per-process pool multiplied by more processes than you remembered running, or connections parked in &lt;code&gt;idle in transaction&lt;/code&gt;. Adding PgBouncer fixes the symptom, but if you install it without finding the multiplier first, you get the same failure a month later with an extra moving part in the path.&lt;/p&gt;

&lt;p&gt;Here is the order I work through it: count the connections and their states, find the multiplier, shrink the app-side pool, and only then decide whether a pooler is actually warranted.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does "sorry, too many clients already" actually mean?
&lt;/h2&gt;

&lt;p&gt;Postgres allocates a backend process per connection, and &lt;code&gt;max_connections&lt;/code&gt; is a hard ceiling set at server start. When clients exceed it, the connection attempt is rejected outright. You'll see one of two messages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FATAL:  sorry, too many clients already
FATAL:  remaining connection slots are reserved for non-replication superuser connections
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second one is worth knowing separately: it means you've hit &lt;code&gt;max_connections&lt;/code&gt; minus &lt;code&gt;superuser_reserved_connections&lt;/code&gt;, so ordinary users are locked out while the reserved slots keep a superuser login available for exactly this situation. That's the escape hatch that lets you connect as a superuser and look around while the app is failing.&lt;/p&gt;

&lt;p&gt;Both are refusals at the door, not signs of load. CPU and IO can be near idle while this happens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: this error is a counting problem, not a capacity problem — treat it as arithmetic until the arithmetic proves otherwise.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I find out where the connections are going?
&lt;/h2&gt;

&lt;p&gt;Connect (as superuser if you're locked out) and group &lt;code&gt;pg_stat_activity&lt;/code&gt; by the two things that matter: who opened it, and what it's doing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;usename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;application_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;state_change&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;longest_in_state&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;backend_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'client backend'&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&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;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three patterns show up over and over:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A wall of &lt;code&gt;idle&lt;/code&gt; connections&lt;/strong&gt; with a single &lt;code&gt;application_name&lt;/code&gt;. That's a pool that's sized correctly per process but replicated across more processes than you think.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;idle in transaction&lt;/code&gt;, with &lt;code&gt;longest_in_state&lt;/code&gt; in minutes.&lt;/strong&gt; A code path opened a transaction, did something slow or fallible outside the database (an HTTP call, usually), and never committed. Those connections hold locks and are unusable by anyone else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A scattering of &lt;code&gt;active&lt;/code&gt; connections all above your intended pool size.&lt;/strong&gt; Something bypasses the pool — a migration runner, a cron job, an admin tool, a metrics exporter.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;idle in transaction&lt;/code&gt; case has a server-side guard worth setting regardless of how you resolve the rest:&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;SYSTEM&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;idle_in_transaction_session_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'60s'&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;pg_reload_conf&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That kills sessions that hold a transaction open past a minute. It converts a silent connection leak into a loud, attributable application error, which is strictly better.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: if &lt;code&gt;idle in transaction&lt;/code&gt; shows up at all, fix that before touching pool sizes — a pooler will happily leak those too.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is my app pool bigger than I think?
&lt;/h2&gt;

&lt;p&gt;The multiplier is where the number actually comes from. Your config says pool size 10; your database sees 240. The count is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pool_size x processes_per_instance x instances (x 2 if you run a separate replica/read pool)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Gunicorn or Puma deployment with 8 workers, a pool of 10, across 3 pods is 240 connections before anything unusual happens. Add a background worker deployment with the same defaults and you're past a default &lt;code&gt;max_connections&lt;/code&gt; of 100 several times over. Serverless is the extreme version: every warm function instance holds its own connection, and concurrency spikes create instances faster than any pool config can constrain.&lt;/p&gt;

&lt;p&gt;Most application pools also have a burst setting beyond the nominal size — SQLAlchemy's &lt;code&gt;max_overflow&lt;/code&gt;, HikariCP's &lt;code&gt;maximumPoolSize&lt;/code&gt; versus minimum idle — so multiply the ceiling, not the steady state.&lt;/p&gt;

&lt;p&gt;And the pool is usually too big to begin with. The sizing guidance that has held up best for me is the one HikariCP has documented for years: connections should be a small multiple of your core count, not a function of your request concurrency. Beyond that, the extra connections just queue inside Postgres instead of inside your app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: multiply your pool ceiling by every process and every replica before you conclude the database is the problem.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When is a connection pooler actually worth adding?
&lt;/h2&gt;

&lt;p&gt;Shrinking pools solves it for a fixed set of long-lived processes. A pooler earns its place when the client count is genuinely unbounded — serverless, autoscaled workers, many small services sharing one database — or when you need thousands of client connections multiplexed onto a few dozen server ones.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Runs where&lt;/th&gt;
&lt;th&gt;Best fit&lt;/th&gt;
&lt;th&gt;Main drawback&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Smaller app pool&lt;/td&gt;
&lt;td&gt;Nowhere new&lt;/td&gt;
&lt;td&gt;Fixed process count, single service&lt;/td&gt;
&lt;td&gt;Doesn't survive autoscaling or serverless&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PgBouncer&lt;/td&gt;
&lt;td&gt;Sidecar, VM, or container&lt;/td&gt;
&lt;td&gt;Anything self-managed; the default choice&lt;/td&gt;
&lt;td&gt;Single-threaded per process; you manage HA yourself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Supavisor&lt;/td&gt;
&lt;td&gt;Managed on Supabase&lt;/td&gt;
&lt;td&gt;Postgres on Supabase, incl. serverless clients&lt;/td&gt;
&lt;td&gt;Tied to that platform&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;pgcat&lt;/td&gt;
&lt;td&gt;Sidecar or standalone&lt;/td&gt;
&lt;td&gt;Pooling plus load balancing across replicas&lt;/td&gt;
&lt;td&gt;Smaller ecosystem; fewer people to ask when it misbehaves&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RDS Proxy&lt;/td&gt;
&lt;td&gt;Managed by AWS&lt;/td&gt;
&lt;td&gt;Lambda against RDS/Aurora, IAM auth&lt;/td&gt;
&lt;td&gt;Per-connection pricing; pinning silently kills the benefit&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you're self-hosting and want the option with the longest operational track record, PgBouncer is the one that multiplexes thousands of clients onto a small server pool with a config file you can read in one sitting. If your Postgres is already on Supabase, Supavisor is the pooler that's designed for that platform's connection strings and handles serverless client churn without extra infrastructure. If you're running Lambda against RDS or Aurora and want AWS to own the uptime, RDS Proxy is the one that integrates with IAM authentication and Secrets Manager instead of asking you to distribute database passwords.&lt;/p&gt;

&lt;p&gt;A minimal PgBouncer config for the common case:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="nn"&gt;[databases]&lt;/span&gt;
&lt;span class="py"&gt;appdb&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;host=10.0.1.20 port=5432 dbname=appdb&lt;/span&gt;

&lt;span class="nn"&gt;[pgbouncer]&lt;/span&gt;
&lt;span class="py"&gt;listen_addr&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;0.0.0.0&lt;/span&gt;
&lt;span class="py"&gt;listen_port&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;6432&lt;/span&gt;
&lt;span class="py"&gt;auth_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;scram-sha-256&lt;/span&gt;
&lt;span class="py"&gt;auth_file&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;/etc/pgbouncer/userlist.txt&lt;/span&gt;
&lt;span class="py"&gt;pool_mode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;transaction&lt;/span&gt;
&lt;span class="py"&gt;default_pool_size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;20&lt;/span&gt;
&lt;span class="py"&gt;max_client_conn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;1000&lt;/span&gt;
&lt;span class="py"&gt;server_idle_timeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;600&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;default_pool_size&lt;/code&gt; is how many real Postgres connections this pool will ever hold; &lt;code&gt;max_client_conn&lt;/code&gt; is how many clients may queue against it. Once it's running, &lt;code&gt;SHOW POOLS;&lt;/code&gt; on the admin console tells you whether the size is right — &lt;code&gt;cl_waiting&lt;/code&gt; and &lt;code&gt;maxwait&lt;/code&gt; above zero mean clients are queuing for a server connection, which is your signal to raise &lt;code&gt;default_pool_size&lt;/code&gt; or find the slow queries holding connections.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: a pooler is for unbounded client counts; for a fixed number of processes, the cheaper fix is to stop over-provisioning the pool.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What breaks when you switch to transaction pooling?
&lt;/h2&gt;

&lt;p&gt;This is the part that surprises people, because the migration looks like a hostname and port change. In &lt;code&gt;pool_mode = transaction&lt;/code&gt;, a server connection is handed back to the pool at the end of every transaction, so any state that lives on the session rather than the transaction is gone or, worse, leaks to whoever gets that connection next. That rules out &lt;code&gt;LISTEN&lt;/code&gt;/&lt;code&gt;NOTIFY&lt;/code&gt;, session-level advisory locks, &lt;code&gt;SET&lt;/code&gt; outside a transaction, &lt;code&gt;WITH HOLD&lt;/code&gt; cursors, and temp tables.&lt;/p&gt;

&lt;p&gt;The one that actually bites is prepared statements. Drivers that transparently prepare and cache statements — JDBC, asyncpg, psycopg3 — will send a named statement on one server connection and try to execute it on another:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR:  prepared statement "S_1" does not exist
ERROR:  prepared statement "S_1" already exists
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Modern PgBouncer (1.21 and later) can track named prepared statements in transaction mode, but you have to opt in with &lt;code&gt;max_prepared_statements&lt;/code&gt;; if it's left at the disabled default, you get the errors above no matter how new the binary is. Check the default for the exact version you're running before assuming it's on.&lt;/p&gt;

&lt;p&gt;The client-side fixes, if you'd rather not rely on the pooler:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# asyncpg: disable the statement cache
&lt;/span&gt;&lt;span class="n"&gt;conn&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;asyncpg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dsn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;statement_cache_size&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="c1"&gt;# psycopg3: never use the extended protocol's named prepares
&lt;/span&gt;&lt;span class="n"&gt;conn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;psycopg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dsn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prepare_threshold&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# SQLAlchemy in serverless or behind a transaction-mode pooler:
# don't pool twice — let the pooler own it
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;create_engine&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;sqlalchemy.pool&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;NullPool&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_engine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DSN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;poolclass&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;NullPool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For JDBC, the equivalent is &lt;code&gt;prepareThreshold=0&lt;/code&gt; in the connection URL.&lt;/p&gt;

&lt;p&gt;Keep one session-mode port (or a separate connection string on 5432) for migrations and admin tools. Schema migrations, &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt;, and anything using advisory locks belong on a direct connection, not the transaction pool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: transaction pooling is a contract change, not a hostname change — audit for session state and prepared statements before you cut over.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Why do I get "too many clients already" when my Postgres CPU is idle?&lt;/strong&gt;&lt;br&gt;
Because the limit is &lt;code&gt;max_connections&lt;/code&gt;, a fixed count set at server start, not a resource threshold. Postgres refuses the connection at the door regardless of how little work the existing backends are doing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I just raise max_connections instead of adding a pooler?&lt;/strong&gt;&lt;br&gt;
Only modestly, and only if you have the memory for it — each connection is a backend process with its own overhead, and thousands of mostly-idle backends degrade throughput even when they're doing nothing. Raising it from 100 to a few hundred on a well-provisioned server is reasonable; treating it as the answer to serverless connection churn is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use PgBouncer transaction mode with an ORM?&lt;/strong&gt;&lt;br&gt;
Yes, provided you disable the driver's prepared-statement cache (or enable &lt;code&gt;max_prepared_statements&lt;/code&gt; in PgBouncer), stop the ORM from maintaining its own connection pool on top, and route migrations to a direct session-mode connection.&lt;/p&gt;

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

&lt;p&gt;Start by counting: group &lt;code&gt;pg_stat_activity&lt;/code&gt; by state and application, and set &lt;code&gt;idle_in_transaction_session_timeout&lt;/code&gt; so leaks announce themselves. If your process count is fixed, shrink the per-process pool to a small multiple of your cores and the error usually disappears without new infrastructure. Add a pooler when client count is genuinely unbounded — PgBouncer for self-managed setups, Supavisor if you're on Supabase, RDS Proxy if you want AWS to own it. Whichever you choose, treat the move to transaction mode as an application change: audit session state, disable driver-side prepared statements, and keep a direct connection reserved for migrations.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/cloudflare-for-developers-what-its-great-at-where-it-bites-and-how-to-actually-use-it-16k6"&gt;Cloudflare for Developers: What It's Great At, Where It Bites, and How to Actually Use It&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/the-hidden-costs-of-serverless-what-your-first-big-bill-teaches-you-5d3d"&gt;The Hidden Costs of Serverless: What Your First Big Bill Teaches You&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/supabase-vs-firebase-in-2026-the-migration-questions-nobody-answers-35h8"&gt;Supabase vs Firebase in 2026: The Migration Questions Nobody Answers&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>devops</category>
      <category>performance</category>
    </item>
    <item>
      <title>Why Your Postgres Migration Locked the Whole Table (and the Pattern That Doesn't)</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Sat, 15 Aug 2026 12:13:15 +0000</pubDate>
      <link>https://dev.to/libme/why-your-postgres-migration-locked-the-whole-table-and-the-pattern-that-doesnt-38k4</link>
      <guid>https://dev.to/libme/why-your-postgres-migration-locked-the-whole-table-and-the-pattern-that-doesnt-38k4</guid>
      <description>&lt;p&gt;The ALTER TABLE itself is usually not slow — the outage comes from Postgres queueing every other query behind the lock it is waiting for. The fix is two habits: set &lt;code&gt;lock_timeout&lt;/code&gt; on every migration session so a blocked DDL statement fails fast instead of freezing traffic, and split anything that rewrites or validates a table into a non-blocking two-step (&lt;code&gt;NOT VALID&lt;/code&gt; then &lt;code&gt;VALIDATE&lt;/code&gt;, or &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt;). Everything else in this post is detail on which operations need which treatment.&lt;/p&gt;

&lt;p&gt;This is written for Postgres 12 and up, which is where the cheap paths for most of these operations landed. Behavior described here holds as of mid-2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why did a one-second ALTER TABLE take the whole app down?
&lt;/h2&gt;

&lt;p&gt;The symptom is confusing the first time. Your migration adds a column. The migration log shows it eventually completed in a few hundred milliseconds. Meanwhile the API returned 504s for four minutes, and your error tracker is full of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR:  canceling statement due to statement timeout
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is what actually happened. Most DDL takes an &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; lock, which conflicts with everything, including plain &lt;code&gt;SELECT&lt;/code&gt;. Your &lt;code&gt;ALTER TABLE&lt;/code&gt; asked for that lock while some long-running query — an analytics &lt;code&gt;SELECT&lt;/code&gt;, an idle-in-transaction session left open by a connection pool, a &lt;code&gt;pg_dump&lt;/code&gt; — still held &lt;code&gt;ACCESS SHARE&lt;/code&gt;. So the ALTER went into the lock queue and waited.&lt;/p&gt;

&lt;p&gt;The part people miss: &lt;strong&gt;Postgres queues lock requests in order&lt;/strong&gt;. Once your ALTER is waiting, every new query that needs any conflicting lock on that table lines up &lt;em&gt;behind&lt;/em&gt; it, even though those queries could have run fine against the old table. One waiting DDL statement converts a slow query into a full table outage.&lt;/p&gt;

&lt;p&gt;You can watch it happen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;wait_event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&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;query&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_activity&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ANY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pg_blocking_pids&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;your_migration_pid&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns a 20-minute-old &lt;code&gt;SELECT&lt;/code&gt; from a BI tool, you have found the real cause. The migration was never the problem; it was the thing standing in the doorway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: a blocked migration is more dangerous than a slow one, because everything behind it in the lock queue blocks too.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Postgres migrations are actually safe to run at any time?
&lt;/h2&gt;

&lt;p&gt;Not every DDL statement is a hazard. The distinction that matters is whether the operation needs to rewrite the table, scan it, or merely update the catalog.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;Lock behavior&lt;/th&gt;
&lt;th&gt;Safe on a large, busy table?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ADD COLUMN&lt;/code&gt; (nullable, or with a non-volatile &lt;code&gt;DEFAULT&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Brief &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt;, catalog-only&lt;/td&gt;
&lt;td&gt;Yes, with &lt;code&gt;lock_timeout&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ADD COLUMN ... DEFAULT now()&lt;/code&gt; (volatile default)&lt;/td&gt;
&lt;td&gt;Full table rewrite&lt;/td&gt;
&lt;td&gt;No — backfill instead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DROP COLUMN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Brief &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt;, catalog-only&lt;/td&gt;
&lt;td&gt;Yes, but breaks &lt;code&gt;SELECT *&lt;/code&gt; clients&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CREATE INDEX&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Blocks writes for the whole build&lt;/td&gt;
&lt;td&gt;No — use &lt;code&gt;CONCURRENTLY&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SHARE UPDATE EXCLUSIVE&lt;/code&gt;, allows reads and writes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;SET NOT NULL&lt;/code&gt; directly&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; + full scan&lt;/td&gt;
&lt;td&gt;No — validate a &lt;code&gt;CHECK&lt;/code&gt; first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ADD FOREIGN KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; + scan of both tables&lt;/td&gt;
&lt;td&gt;No — use &lt;code&gt;NOT VALID&lt;/code&gt; then &lt;code&gt;VALIDATE&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ALTER COLUMN TYPE varchar(50) -&amp;gt; varchar(100)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Catalog-only&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ALTER COLUMN TYPE int -&amp;gt; bigint&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full table rewrite&lt;/td&gt;
&lt;td&gt;No — new column, backfill, swap&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;RENAME COLUMN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Catalog-only, instant&lt;/td&gt;
&lt;td&gt;Only with expand/contract deploys&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two columns to internalize: "catalog-only" operations are cheap but still need the lock, so they still need a timeout. "Rewrite or scan" operations are the ones that need a different shape entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: the question is never "is this ALTER fast?" but "does it rewrite, does it scan, and how long will it wait for its lock?"&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The lock_timeout retry pattern
&lt;/h2&gt;

&lt;p&gt;Every migration session should refuse to wait. Set &lt;code&gt;lock_timeout&lt;/code&gt; low — a couple of seconds — so a contended DDL statement gives up instead of building a queue behind it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;lock_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'3s'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;fulfillment_note&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the lock is busy, you get this instead of an outage:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR:  canceling statement due to lock timeout
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That error is a success. It means the guard worked. Then you retry, because the blocking query has usually finished by the next attempt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;psycopg&lt;/span&gt;

&lt;span class="n"&gt;DDL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ALTER TABLE orders ADD COLUMN fulfillment_note text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;migrate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dsn&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;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;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&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;attempt&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attempts&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;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;psycopg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dsn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;autocommit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&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;with&lt;/span&gt; &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SET lock_timeout = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;3s&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                    &lt;span class="n"&gt;cur&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DDL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;conn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;applied on attempt &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="si"&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;return&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;psycopg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LockNotAvailable&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;wait&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attempt &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; blocked; retrying in &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;wait&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;time&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;wait&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;could not acquire lock; investigate long-running queries&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that trip people up. &lt;code&gt;lock_timeout&lt;/code&gt; is per-session, not per-statement, so it has to be set on the same connection that runs the DDL — setting it in a different session from your migration tool does nothing. And &lt;code&gt;statement_timeout&lt;/code&gt; is not a substitute: it caps total execution time, which will also kill a legitimate long &lt;code&gt;VALIDATE CONSTRAINT&lt;/code&gt; you actually wanted to finish.&lt;/p&gt;

&lt;p&gt;Most migration frameworks let you set this once. In Django you can put &lt;code&gt;SET lock_timeout&lt;/code&gt; in a &lt;code&gt;RunSQL&lt;/code&gt; at the top of the migration; in Rails, &lt;code&gt;disable_ddl_transaction!&lt;/code&gt; plus a &lt;code&gt;lock_timeout&lt;/code&gt; in the connection setup does it. If you would rather not hand-roll the guard, Squawk lints Postgres migration files in CI and fails the build when a statement takes an unsafe lock, which catches the mistake before it reaches production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: a migration that fails with "lock timeout" is a working safety net, not a broken deploy.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you add a NOT NULL column to a large table without a rewrite?
&lt;/h2&gt;

&lt;p&gt;Directly running &lt;code&gt;ALTER TABLE ... SET NOT NULL&lt;/code&gt; forces a full sequential scan under &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; to prove no nulls exist. On a large table that is minutes of total downtime.&lt;/p&gt;

&lt;p&gt;Since Postgres 12, you can prove the same thing with a &lt;code&gt;CHECK&lt;/code&gt; constraint that validates without blocking writes, and Postgres will then accept &lt;code&gt;SET NOT NULL&lt;/code&gt; without rescanning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- 1. Add the constraint unvalidated. Instant; applies to new rows only.&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
  &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;orders_fulfillment_note_not_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;fulfillment_note&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;VALID&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 2. Backfill in batches, committing each batch.&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;fulfillment_note&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;fulfillment_note&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;IN&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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;fulfillment_note&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- 3. Validate. Takes SHARE UPDATE EXCLUSIVE: reads and writes keep running.&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;VALIDATE&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;orders_fulfillment_note_not_null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 4. Now cheap, because the validated CHECK already proves it.&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;lock_timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'3s'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;fulfillment_note&lt;/span&gt; &lt;span class="k"&gt;SET&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="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;orders_fulfillment_note_not_null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same &lt;code&gt;NOT VALID&lt;/code&gt; / &lt;code&gt;VALIDATE&lt;/code&gt; split works for foreign keys, and it is the single most useful trick in this whole area.&lt;/p&gt;

&lt;p&gt;Index creation has its own version of this: &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; cannot run inside a transaction block, which is why ORMs that wrap every migration in a transaction fail with &lt;code&gt;CREATE INDEX CONCURRENTLY cannot run inside a transaction block&lt;/code&gt;. Mark the migration non-transactional. Also check the result afterward — a concurrent build that fails leaves an invalid index behind that silently does nothing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;indexrelid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;index_name&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_index&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="n"&gt;indisvalid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop and rebuild anything that shows up there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: any operation that needs to scan the table has a two-step form that moves the scan out from under the exclusive lock.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What about renames, drops, and type changes?
&lt;/h2&gt;

&lt;p&gt;These are not lock problems — they are deploy-ordering problems. &lt;code&gt;RENAME COLUMN&lt;/code&gt; is instant in the catalog, and that is exactly what makes it dangerous: the old application code is still running and still selecting the old name.&lt;/p&gt;

&lt;p&gt;The pattern is expand/contract, and it takes three deploys:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Expand.&lt;/strong&gt; Add the new column. Write to both old and new from application code. Backfill the old rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migrate reads.&lt;/strong&gt; Deploy code that reads the new column. Old code is gone at this point.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contract.&lt;/strong&gt; Stop writing the old column, then drop it in a later deploy.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The same three-step shape covers &lt;code&gt;int&lt;/code&gt; → &lt;code&gt;bigint&lt;/code&gt; on a primary key, splitting one column into two, and moving a column to another table. It is slow in calendar time and completely uneventful in production, which is the trade you want.&lt;/p&gt;

&lt;p&gt;One more footgun worth naming: &lt;code&gt;DROP COLUMN&lt;/code&gt; is catalog-only and fast, but any client doing &lt;code&gt;SELECT *&lt;/code&gt; into a struct or row class with strict field mapping will break the moment the column disappears. Deploy the code change first.&lt;/p&gt;

&lt;p&gt;If you would rather have this orchestrated for you than hand-written, pgroll implements expand/contract by exposing versioned views of the schema so old and new application versions each see the shape they expect. For teams that want migrations reviewed as declarative schema state with destructive-change linting in CI, Atlas is the one that treats the schema as code and diffs it rather than asking you to write the steps by hand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: renames and drops are safe at the database level and unsafe at the deploy level — sequence the code, not the lock.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;What does "canceling statement due to lock timeout" mean in Postgres?&lt;/strong&gt;&lt;br&gt;
It means your statement waited longer than &lt;code&gt;lock_timeout&lt;/code&gt; for a lock another session was holding, so Postgres aborted it. It is a protective failure, not corruption — nothing was applied, and retrying after the blocking query finishes normally succeeds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does ALTER TABLE ADD COLUMN lock the table in Postgres?&lt;/strong&gt;&lt;br&gt;
Yes, it takes a brief &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; lock, but since Postgres 11 adding a column with a constant default no longer rewrites the table, so the lock is held for milliseconds. The risk is not the duration of the ALTER — it is how long the ALTER waits in the lock queue while other queries pile up behind it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I add an index in production without downtime?&lt;/strong&gt;&lt;br&gt;
Use &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt;, and run it outside a transaction block. It takes roughly twice as long and can leave an invalid index behind if it fails, so check &lt;code&gt;pg_index WHERE NOT indisvalid&lt;/code&gt; afterward and rebuild anything listed.&lt;/p&gt;

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

&lt;p&gt;If you change one thing after reading this, set &lt;code&gt;lock_timeout&lt;/code&gt; on your migration connection and add a retry loop — that alone converts the most common Postgres migration outage into a retried deploy step. Learn the &lt;code&gt;NOT VALID&lt;/code&gt; / &lt;code&gt;VALIDATE&lt;/code&gt; split next, since it covers &lt;code&gt;NOT NULL&lt;/code&gt;, foreign keys, and check constraints with one idea. Use expand/contract for anything that changes a column's name, type, or existence, and accept that it costs three deploys. Reach for tooling — a CI linter like Squawk, or a full orchestrator like pgroll or Atlas — once you have more than a couple of people writing migrations and code review stops catching the unsafe ones reliably.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/postgres-full-text-search-in-production-how-to-load-test-the-index-and-pin-down-relevance-282b"&gt;Postgres Full-Text Search in Production: How to Load-Test the Index and Pin Down Relevance&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/how-long-should-you-keep-idempotency-keys-a-ttl-strategy-for-webhook-dedup-4ce9"&gt;How Long Should You Keep Idempotency Keys? A TTL Strategy for Webhook Dedup&lt;/a&gt;&lt;/li&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;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>devops</category>
      <category>sql</category>
    </item>
    <item>
      <title>Free Tier Traps: How Hobby Plans Are Designed and When They Flip to Paid</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Fri, 14 Aug 2026 22:57:07 +0000</pubDate>
      <link>https://dev.to/libme/free-tier-traps-how-hobby-plans-are-designed-and-when-they-flip-to-paid-450e</link>
      <guid>https://dev.to/libme/free-tier-traps-how-hobby-plans-are-designed-and-when-they-flip-to-paid-450e</guid>
      <description>&lt;p&gt;A free tier is a marketing budget with a shutoff valve, and the valve is placed on whatever metric grows when your project starts working. The trap is rarely the price you eventually pay — it's that the flip arrives on the vendor's schedule, not yours, usually as a hard stop rather than a slow ramp. The practical defense is to know which dimension each vendor meters, and to keep the two or three pieces that would be painful to move sitting behind an interface you control.&lt;/p&gt;

&lt;p&gt;I've had a side project paused, a database run out of connection slots on a Sunday, and a CI pipeline stop mid-release because the month's minutes were gone. None of those were surprises in hindsight. All of them were surprises at the time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do vendors give away a free tier at all?
&lt;/h2&gt;

&lt;p&gt;There are only a few reasons a company hands you compute for nothing, and each one produces a different shape of limit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Acquisition funnels.&lt;/strong&gt; The free tier exists to get a credit card on file eventually. These are usually generous on the dimension you notice (deploys, projects, requests) and tight on the dimension that costs the vendor money (bandwidth, storage, seats). They're stable for years because the free users are the pipeline.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Developer mindshare.&lt;/strong&gt; The vendor wants you to reach for them at work. Limits here tend to be per-project rather than per-account, and the friction is deliberately placed at &lt;em&gt;team&lt;/em&gt; features — SSO, roles, audit logs, shared environments — not at raw capacity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Capacity dumping.&lt;/strong&gt; Sometimes free tier is just idle infrastructure being monetized as attention. This is the least stable category, because when the underlying economics move, the tier moves with them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Open core.&lt;/strong&gt; The free thing is the software, not the hosting. These flip in a different way: the product stays free, but the feature you now depend on migrates into an enterprise edition.&lt;/p&gt;

&lt;p&gt;That last distinction matters more than most people weigh it. A hosted free tier can be withdrawn overnight; a permissively-licensed open source project can only be forked away from you slowly, and you keep the running copy either way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: figure out which of the four reasons you're benefiting from, because it predicts how much notice you'll get.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually flips a hobby plan to paid?
&lt;/h2&gt;

&lt;p&gt;The flip almost never comes from the metric in the marketing table. It comes from a second-order metric you weren't tracking.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flip trigger&lt;/th&gt;
&lt;th&gt;What it looks like in practice&lt;/th&gt;
&lt;th&gt;Warning you get&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sustained egress/bandwidth&lt;/td&gt;
&lt;td&gt;One image-heavy page gets linked somewhere&lt;/td&gt;
&lt;td&gt;Usually an email, sometimes after the fact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compute-seconds, not requests&lt;/td&gt;
&lt;td&gt;A slow endpoint or an N+1 in a background job&lt;/td&gt;
&lt;td&gt;Dashboard graph you have to look at&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Concurrent connections&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;remaining connection slots are reserved&lt;/code&gt; at the worst time&lt;/td&gt;
&lt;td&gt;None — it's a hard error&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inactivity&lt;/td&gt;
&lt;td&gt;Project auto-paused after days of no traffic&lt;/td&gt;
&lt;td&gt;Notification you'll miss&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rows / storage growth&lt;/td&gt;
&lt;td&gt;A logging or events table nobody prunes&lt;/td&gt;
&lt;td&gt;Gradual, then a write failure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Seats&lt;/td&gt;
&lt;td&gt;You add one collaborator&lt;/td&gt;
&lt;td&gt;Immediate paywall&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Policy change&lt;/td&gt;
&lt;td&gt;The tier itself is retired&lt;/td&gt;
&lt;td&gt;Weeks to months of notice&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The connection-limit case is the one that ruins weekends. On a small managed Postgres, the connection ceiling is low and serverless functions each want their own connection, so a modest traffic bump produces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FATAL: remaining connection slots are reserved for non-replication superuser connections
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is not a "you've grown, please upgrade" message. It's an outage. The fix is a pooler, not a plan upgrade — and knowing that difference is worth real money.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Pool once per process, not per request. In serverless, cap it hard&lt;/span&gt;
&lt;span class="c1"&gt;// and point at the pooler endpoint, not the direct database host.&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;Pool&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;pg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Pool&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;connectionString&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// pooler URL (often port 6543)&lt;/span&gt;
  &lt;span class="na"&gt;max&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;// one connection per function instance&lt;/span&gt;
  &lt;span class="na"&gt;idleTimeoutMillis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// release fast so instances don't hoard slots&lt;/span&gt;
  &lt;span class="na"&gt;connectionTimeoutMillis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt; &lt;span class="c1"&gt;// fail fast instead of piling up&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;release&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;If your platform's free tier gives you a pooled connection string, use it from day one even when you don't need it. Migrating to it under load is a much worse afternoon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: the limit that stops you is the one that isn't on the pricing page.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Which limits bite first, by service category?
&lt;/h2&gt;

&lt;p&gt;As of mid-2026 the patterns below hold across most vendors in each category, though the specific numbers change constantly — always check the current pricing page rather than trusting a number you read in a blog post, including this one.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;Metered dimension that bites&lt;/th&gt;
&lt;th&gt;Typically fine on free tier&lt;/th&gt;
&lt;th&gt;Move-off signal&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Static/edge hosting&lt;/td&gt;
&lt;td&gt;Bandwidth, build minutes, commercial-use clause&lt;/td&gt;
&lt;td&gt;Personal sites, docs, demos&lt;/td&gt;
&lt;td&gt;Any revenue-generating traffic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Managed Postgres&lt;/td&gt;
&lt;td&gt;Connections, storage, inactivity pausing&lt;/td&gt;
&lt;td&gt;Prototypes, low-write apps&lt;/td&gt;
&lt;td&gt;Background jobs + real users&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Serverless functions&lt;/td&gt;
&lt;td&gt;GB-seconds and cold-start behavior&lt;/td&gt;
&lt;td&gt;Sporadic traffic&lt;/td&gt;
&lt;td&gt;Anything with steady load&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI&lt;/td&gt;
&lt;td&gt;Minutes/month, concurrency&lt;/td&gt;
&lt;td&gt;Small repos, few branches&lt;/td&gt;
&lt;td&gt;Matrix builds, monorepos&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error tracking&lt;/td&gt;
&lt;td&gt;Event volume, retention window&lt;/td&gt;
&lt;td&gt;Early apps&lt;/td&gt;
&lt;td&gt;Any noisy dependency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth&lt;/td&gt;
&lt;td&gt;MAU threshold, and which features sit above it&lt;/td&gt;
&lt;td&gt;Single-app, single-tenant&lt;/td&gt;
&lt;td&gt;Orgs, SSO, custom domains&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two specifics worth naming. Cloudflare's free tier is the most durable in the static/edge category because its economics come from network scale rather than from converting hobbyists, but its worker runtime is genuinely different from Node and porting is real work. Supabase's free tier is the most complete "whole backend" offer, but free projects pause after a stretch of inactivity — fine for a portfolio piece, disqualifying for anything a client might open unannounced.&lt;/p&gt;

&lt;p&gt;Also, read the commercial-use language. Several excellent free tiers are explicitly for non-commercial or personal projects, and the moment your side project takes payments you are out of compliance regardless of usage volume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: pick the free tier whose metered dimension is the one your app grows slowest on.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you know a free tier is about to disappear?
&lt;/h2&gt;

&lt;p&gt;History gives a usable prior. Heroku retired free dynos in late 2022, Railway replaced its free tier with a trial in 2023, and PlanetScale removed its hobby tier in 2024 — each after a period where the free plan was visibly the most-discussed thing about the product. The pattern is consistent: free tiers get withdrawn when the company shifts from growth-at-any-cost to gross-margin discipline, which for infrastructure vendors tends to follow a funding round or an enterprise push.&lt;/p&gt;

&lt;p&gt;Signals I now treat as a soft deadline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The free tier stops being mentioned in launch posts and docs quickstarts.&lt;/li&gt;
&lt;li&gt;Support responses start routing free users to community channels only.&lt;/li&gt;
&lt;li&gt;A "usage-based" plan appears &lt;em&gt;below&lt;/em&gt; the old paid plan — the free tier is being repriced, not removed, and it's next.&lt;/li&gt;
&lt;li&gt;The company announces enterprise features (SSO, compliance certifications) as its main roadmap theme.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are proof. All of them are enough to spend an hour making sure you could leave.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: when a vendor stops marketing its free tier, start planning like it's already gone.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What does a portable setup actually look like?
&lt;/h2&gt;

&lt;p&gt;You don't need multi-cloud abstraction. You need three things checked before you build on any free tier:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Can I export my data unassisted?&lt;/strong&gt; A &lt;code&gt;pg_dump&lt;/code&gt;-compatible database or an S3-compatible bucket is portable. A proprietary document store with an export button that emails you a link is not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the runtime standard?&lt;/strong&gt; Code that runs on plain Node/Python containers moves in an afternoon. Code written against a vendor-specific runtime, KV store, or auth SDK moves in a sprint.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is config in the repo?&lt;/strong&gt; If redeploying elsewhere means recreating settings you only ever clicked into a dashboard, you don't have a project, you have a pet.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Concretely, keep vendor SDKs behind a thin module of your own — one file with &lt;code&gt;getUser()&lt;/code&gt;, &lt;code&gt;sendEmail()&lt;/code&gt;, &lt;code&gt;putObject()&lt;/code&gt; — so the blast radius of a pricing change is a file, not a codebase. This costs an hour up front and it's the single highest-return hour in a side project.&lt;/p&gt;

&lt;p&gt;You can also just watch your usage. Most platforms expose it via API, so a weekly check costs nothing:&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;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="c"&gt;# GitHub Actions minutes for the authenticated user (endpoint as of mid-2026;&lt;/span&gt;
&lt;span class="c"&gt;# verify against current REST docs if it 404s).&lt;/span&gt;
gh api /users/&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gh api /user &lt;span class="nt"&gt;--jq&lt;/span&gt; .login&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/settings/billing/actions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'"used: \(.total_minutes_used)/\(.included_minutes) minutes"'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wire that into a weekly cron or a CI job and the flip stops being an ambush.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Takeaway: portability is a property of your export path and your runtime, not of your provider's promises.&lt;/strong&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Is it safe to build a real product on a free tier?&lt;/strong&gt;&lt;br&gt;
Build on it, yes; depend on it, no. Use the free tier for the prototype and the pre-revenue phase, but only if the data export path and the runtime are standard enough that you could move in a day. The moment the project takes payments, budget for the paid plan and check the free tier's commercial-use terms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why did my free tier database suddenly stop accepting connections?&lt;/strong&gt;&lt;br&gt;
Almost always the connection ceiling, not storage. Serverless functions open a connection per instance, so a small traffic spike exhausts the slots and you get &lt;code&gt;remaining connection slots are reserved for non-replication superuser connections&lt;/code&gt;. Switch to the provider's pooled connection string and cap &lt;code&gt;max&lt;/code&gt; at 1 per instance before you consider upgrading.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which free tiers are the most stable long-term?&lt;/strong&gt;&lt;br&gt;
The ones where free users cost the vendor almost nothing relative to its scale, or where the free thing is open source software you can self-host. Free tiers offered as an acquisition funnel by a venture-funded startup are the least durable, because their economics depend on a conversion rate that has to improve eventually.&lt;/p&gt;

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

&lt;p&gt;If you're building a portfolio project or a prototype, take the most generous free tier you can find and don't think about it — that's what it's for. If you're building something that might make money, choose based on the metered dimension: pick the vendor whose meter runs on whatever your app produces least. If your project already has users who'd notice an outage, pay for the database and the error tracking first, since those are the two places where a free-tier limit turns into a hard failure rather than a slowdown. And regardless of tier, spend the hour on exports and a thin vendor wrapper — it's cheap insurance against a pricing email you didn't ask for.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/sentry-alternatives-when-error-tracking-bills-grow-faster-than-your-user-base-4pf5"&gt;Sentry Alternatives: When Error Tracking Bills Grow Faster Than Your User Base&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/the-boring-stack-manifesto-why-your-startup-probably-doesnt-need-kubernetes-55bo"&gt;The Boring Stack Manifesto: Why Your Startup Probably Doesn't Need Kubernetes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/supabase-vs-firebase-in-2026-the-migration-questions-nobody-answers-35h8"&gt;Supabase vs Firebase in 2026: The Migration Questions Nobody Answers&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>saas</category>
      <category>cloud</category>
      <category>devops</category>
      <category>startup</category>
    </item>
    <item>
      <title>The Real Break-Even for AI Coding Tools Includes Review Time, Not Just Typing Saved</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Fri, 14 Aug 2026 20:26:34 +0000</pubDate>
      <link>https://dev.to/libme/the-real-break-even-for-ai-coding-tools-includes-review-time-not-just-typing-saved-48nb</link>
      <guid>https://dev.to/libme/the-real-break-even-for-ai-coding-tools-includes-review-time-not-just-typing-saved-48nb</guid>
      <description>&lt;p&gt;Most break-even math for AI coding assistants counts only one side of the ledger: the minutes you save not typing boilerplate. That math is incomplete and usually too optimistic. AI-generated code still has to be read, understood, and verified before it ships, and that review cost lands on a human — often a &lt;em&gt;different, more expensive&lt;/em&gt; human than the one who prompted it. The tool pays off only when the time it saves you writing code is larger than the extra time your team spends confirming that code does what it claims.&lt;/p&gt;

&lt;p&gt;I run AI assistants daily across TypeScript, Python, and Go, and the place they quietly stop paying for themselves is not the subscription line item. It's the pull request that took four minutes to generate and forty minutes to review because nobody could tell at a glance whether the generated error handling was correct or just plausible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does typing-time-saved overstate the value?
&lt;/h2&gt;

&lt;p&gt;The standard pitch measures productivity in keystrokes avoided. Autocomplete finishes your function, an agent scaffolds a module, and you feel fast because your hands did less. But shipping software isn't bottlenecked on typing speed — it's bottlenecked on &lt;em&gt;being sure the code is right&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;AI-generated code has a specific failure signature that makes review more expensive than reviewing a teammate's work: it is fluent, idiomatic, and confidently wrong in ways that don't announce themselves. A human writing unfamiliar code tends to leave signals — a hesitant comment, an obviously naive first pass, a TODO. A model emits the same polished surface whether it nailed the edge case or hallucinated an API that doesn't exist. That fluency inverts the normal review heuristic where "reads cleanly" correlates with "probably fine."&lt;/p&gt;

&lt;p&gt;So the code arrives faster, but each line carries less built-in evidence of correctness. The reviewer has to supply that evidence themselves, which is slower per line than reviewing code whose author can explain every decision.&lt;/p&gt;

&lt;p&gt;Takeaway: the tool moves work from typing (cheap, fast) to verification (expensive, slow) — and if you only count the first, you'll conclude it's free when it isn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the corrected break-even formula look like?
&lt;/h2&gt;

&lt;p&gt;Here's the version I actually use. Let:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Tw&lt;/code&gt; = time the tool saves you writing a change&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Tr&lt;/code&gt; = &lt;em&gt;extra&lt;/em&gt; review/verification time the generated change adds versus code you'd have written yourself&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Rgen&lt;/code&gt; = your effective hourly rate (the person prompting)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Rrev&lt;/code&gt; = the reviewer's effective hourly rate (often a senior, so usually higher)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The change is worth it when the value of writing time saved exceeds the added review cost:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Tw * Rgen  &amp;gt;  Tr * Rrev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things fall out of this immediately. First, review time is weighted by a &lt;em&gt;higher&lt;/em&gt; rate than the time you saved, because in most teams the reviewer is more senior than the author. Second, &lt;code&gt;Tr&lt;/code&gt; can easily exceed &lt;code&gt;Tw&lt;/code&gt; on unfamiliar or subtle code, which flips the inequality even before you factor in rates.&lt;/p&gt;

&lt;p&gt;A quick worked example, using round numbers so you can substitute your own:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;worth_it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;write_saved_min&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;extra_review_min&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gen_rate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rev_rate&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;saved&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;write_saved_min&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;gen_rate&lt;/span&gt;
    &lt;span class="n"&gt;cost&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;extra_review_min&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;rev_rate&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;saved&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;cost&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;saved&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;cost&lt;/span&gt;

&lt;span class="c1"&gt;# CRUD endpoint: tool saves 20 min, adds ~5 min review, similar rates
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;worth_it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# (+20.0, True)  -&amp;gt; clear win
&lt;/span&gt;
&lt;span class="c1"&gt;# Concurrency fix: saves 15 min typing, adds 30 min senior review
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;worth_it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;140&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="c1"&gt;# (-50.0, False) -&amp;gt; net loss
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The subscription fee barely registers next to these numbers. For any working developer, the monthly cost of the tool is dwarfed by a single hour of misdirected review time. The subscription was never the real decision — the review load is.&lt;/p&gt;

&lt;p&gt;Takeaway: if you're arguing about whether the plan is worth the monthly fee, you're optimizing the smallest term in the equation.&lt;/p&gt;

&lt;h2&gt;
  
  
  When does review cost stay low enough to win?
&lt;/h2&gt;

&lt;p&gt;The inequality holds comfortably in exactly the situations the marketing implies, and it collapses in the ones the marketing ignores. The dividing line is &lt;strong&gt;verification difficulty&lt;/strong&gt;, not code volume.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Work type&lt;/th&gt;
&lt;th&gt;Typing saved&lt;/th&gt;
&lt;th&gt;Added review cost&lt;/th&gt;
&lt;th&gt;Net verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Boilerplate CRUD, DTOs, config&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Low — behavior is obvious on read&lt;/td&gt;
&lt;td&gt;Strong win&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Test scaffolding you then tighten&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Low-medium — you own the assertions&lt;/td&gt;
&lt;td&gt;Win&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Well-specified pure functions&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Low — easy to unit-test&lt;/td&gt;
&lt;td&gt;Win&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Glue code across unfamiliar APIs&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;High — must verify the API is real&lt;/td&gt;
&lt;td&gt;Toss-up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Concurrency, auth, money, migrations&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Very high — subtle failure modes&lt;/td&gt;
&lt;td&gt;Usually a loss&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large multi-file refactors&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Very high — diff is too big to trust&lt;/td&gt;
&lt;td&gt;Depends entirely on tests&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern: AI coding tools win where &lt;strong&gt;correctness is cheap to confirm&lt;/strong&gt; — where a human can read the diff and &lt;em&gt;know&lt;/em&gt;, or a fast test can prove it. They lose where confirming correctness requires holding a lot of context in your head, exactly where a subtle bug is most expensive to let through.&lt;/p&gt;

&lt;p&gt;Takeaway: match the tool to code whose correctness you can verify quickly, and the break-even math takes care of itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you keep review cost from eating the savings?
&lt;/h2&gt;

&lt;p&gt;The comment that prompted this post put it well: the savings disappear if a team accepts code faster than it can verify behavior. The fix isn't to use the tool less — it's to make verification cheaper so &lt;code&gt;Tr&lt;/code&gt; stays small.&lt;/p&gt;

&lt;p&gt;Concretely, what has actually lowered my review cost:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Generate the test alongside the code, then read the test first.&lt;/strong&gt; If you can verify the behavior from a focused test you actually understand, you don't have to trace the implementation line by line. The test becomes the receipt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep AI-authored diffs small and single-purpose.&lt;/strong&gt; A 40-line diff is reviewable; a 400-line generated refactor is a rubber stamp waiting to happen. Constrain the prompt to one change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the model explain its risky decisions in the PR description&lt;/strong&gt;, then review the explanation against the code. Where they disagree is your bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Route generated code through the same CI gates as everything else&lt;/strong&gt; — type checks, linters, tests, and a static analyzer catch the confident-but-wrong output cheaply, before a human spends senior time on it. If you want a managed layer that runs deeper semantic checks on every pull request without standing up your own pipeline, GitHub's own Copilot-based PR review and third-party services like SonarQube Cloud are the ones that slot into an existing repo without extra infrastructure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is free either — writing the test costs time. But it's time that converts expensive, unbounded human review into cheap, repeatable machine verification, which is the only move that reliably keeps the inequality on the right side.&lt;/p&gt;

&lt;p&gt;Takeaway: you don't reduce review cost by trusting the tool more — you reduce it by making correctness cheap to prove.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Does an AI coding assistant actually save time overall?&lt;/strong&gt;&lt;br&gt;
It saves net time on code whose correctness is cheap to verify — boilerplate, DTOs, well-specified functions, test scaffolding. On subtle code (concurrency, auth, money, large refactors), the added review time often exceeds the typing time saved, so it can be a net loss even though it feels faster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Should I include code review time in AI tool ROI calculations?&lt;/strong&gt;&lt;br&gt;
Yes. The honest formula compares writing time saved against &lt;em&gt;extra&lt;/em&gt; verification time added, and weights review by the reviewer's hourly rate, which is usually higher than the author's. Leaving review cost out is the single most common way these calculations lie.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is the monthly subscription the main cost of using Copilot?&lt;/strong&gt;&lt;br&gt;
No. For most working developers the subscription is a rounding error next to the value of a single hour of review time. The real cost is human verification of generated code, and that's where the decision should focus.&lt;/p&gt;

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

&lt;p&gt;Count both sides of the ledger. AI coding assistants earn their keep on code you can verify quickly and lose money on code that takes a senior engineer an hour to trust — and the subscription fee is almost never the deciding term. Point the tool at boilerplate and well-tested pure functions, keep generated diffs small, and make tests carry the verification load so review time stays low. Do that, and the break-even math isn't close; skip it, and you'll ship faster right up until the review queue and the 2 a.m. bugs quietly erase the savings.&lt;/p&gt;

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

&lt;ul&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;li&gt;&lt;a href="https://dev.to/libme/vercel-pros-and-cons-when-its-the-right-host-and-when-youll-regret-it-422h"&gt;Vercel Pros and Cons: When It's the Right Host, and When You'll Regret 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;/ul&gt;

</description>
      <category>ai</category>
      <category>productivity</category>
      <category>softwareengineering</category>
      <category>testing</category>
    </item>
    <item>
      <title>Build vs Buy: Authentication in 2026 (Auth0, Clerk, Supabase Auth, or Roll Your Own)</title>
      <dc:creator>Libme</dc:creator>
      <pubDate>Fri, 14 Aug 2026 06:08:09 +0000</pubDate>
      <link>https://dev.to/libme/build-vs-buy-authentication-in-2026-auth0-clerk-supabase-auth-or-roll-your-own-3mc8</link>
      <guid>https://dev.to/libme/build-vs-buy-authentication-in-2026-auth0-clerk-supabase-auth-or-roll-your-own-3mc8</guid>
      <description>&lt;p&gt;If you are choosing auth for a new product in 2026, the honest default is: buy a managed provider for anything user-facing, and only roll your own when you have a specific reason the managed options can't satisfy. Auth0 is the safe enterprise choice with the widest protocol coverage, Clerk is the fastest way to ship a polished sign-in flow for a React/Next.js app, and Supabase Auth is the pragmatic pick when Postgres is already your source of truth. Rolling your own is defensible far less often than developers think — the login form is the easy 10%, and the other 90% is the part that pages you at 2am.&lt;/p&gt;

&lt;p&gt;I've shipped all four approaches at different times, and the mistake I made early was treating this as a code decision. It's an operations decision. Below is the framework I use now, the concrete trade-offs, and the failure modes each option hides.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "roll your own" actually costs
&lt;/h2&gt;

&lt;p&gt;The seductive thing about building auth is that the first commit feels trivial. Hash a password with a modern KDF, store it, compare on login:&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;argon2&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PasswordHasher&lt;/span&gt;

&lt;span class="n"&gt;ph&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PasswordHasher&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;password&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# store this
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stored_hash&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;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;ph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stored_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's correct and it's maybe 1% of the job. The bill comes due when you list everything a real auth system owns: session management and rotation, secure cookie flags, CSRF protection, email verification, password reset with single-use expiring tokens, rate limiting and lockout on login, OAuth social logins (each with its own redirect and token-refresh quirks), MFA (TOTP plus recovery codes), account enumeration protection, and an audit trail. Then the recurring work: rotating signing keys, responding to the next credential-stuffing wave, and keeping up with library CVEs.&lt;/p&gt;

&lt;p&gt;The failure mode I've watched happen twice: a team ships hand-rolled auth, it works fine at 500 users, then a bot runs a credential-stuffing list against the login endpoint. There's no per-account lockout, no anomaly detection, and the "forgot password" flow leaks whether an email exists via a different response time. None of that was in the original ticket, and all of it is table stakes a managed provider gives you on day one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rolling your own auth means signing up to operate a security-critical service forever, not to write a login form once.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When is a managed auth provider worth paying for?
&lt;/h2&gt;

&lt;p&gt;Buy when authentication is necessary but not your differentiator — which is almost always. The value isn't the code you avoid writing; it's the on-call security work you outsource to a team whose entire job is auth.&lt;/p&gt;

&lt;p&gt;Buy specifically when you need social logins and SSO without gluing five OAuth flows together, when SOC 2 or enterprise SSO (SAML/OIDC) is on your roadmap, when you want MFA and passwordless without building the recovery-code edge cases, or when you simply don't have someone who wants to own auth incident response.&lt;/p&gt;

&lt;p&gt;Lean toward building only when you have a genuinely unusual identity model a provider can't express, a hard data-residency or air-gapped requirement, a cost structure where per-MAU pricing becomes punishing at your scale, or auth &lt;em&gt;is&lt;/em&gt; your product. Those are real cases — they're just rarer than the "it's just a login form" instinct suggests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If auth isn't the thing customers pay you for, paying someone else to operate it is usually the cheaper decision once you price in your own time and risk.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Auth0 vs Clerk vs Supabase Auth vs Roll Your Own
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Auth0&lt;/th&gt;
&lt;th&gt;Clerk&lt;/th&gt;
&lt;th&gt;Supabase Auth&lt;/th&gt;
&lt;th&gt;Roll your own&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Best fit&lt;/td&gt;
&lt;td&gt;Enterprise, broad protocol needs&lt;/td&gt;
&lt;td&gt;React/Next.js apps wanting polished UX fast&lt;/td&gt;
&lt;td&gt;Apps already on Postgres/Supabase&lt;/td&gt;
&lt;td&gt;Unusual identity model or hard constraints&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Protocol breadth&lt;/td&gt;
&lt;td&gt;Widest (OIDC, SAML, enterprise connections)&lt;/td&gt;
&lt;td&gt;Growing; strong OAuth/social&lt;/td&gt;
&lt;td&gt;OAuth social + email; SAML on higher tiers&lt;/td&gt;
&lt;td&gt;Whatever you build&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pre-built UI&lt;/td&gt;
&lt;td&gt;Hosted pages, customizable&lt;/td&gt;
&lt;td&gt;Drop-in components, best-in-class DX&lt;/td&gt;
&lt;td&gt;Basic UI helpers; you build most of it&lt;/td&gt;
&lt;td&gt;You build all of it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data ownership&lt;/td&gt;
&lt;td&gt;Provider-hosted identity store&lt;/td&gt;
&lt;td&gt;Provider-hosted identity store&lt;/td&gt;
&lt;td&gt;Users live in &lt;em&gt;your&lt;/em&gt; Postgres&lt;/td&gt;
&lt;td&gt;Fully yours&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lock-in risk&lt;/td&gt;
&lt;td&gt;Higher (proprietary rules/actions)&lt;/td&gt;
&lt;td&gt;Moderate (component-coupled)&lt;/td&gt;
&lt;td&gt;Lower (it's your DB)&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operational burden&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Low-to-medium&lt;/td&gt;
&lt;td&gt;High and permanent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where cost bites&lt;/td&gt;
&lt;td&gt;Enterprise-tier feature gating&lt;/td&gt;
&lt;td&gt;Per-MAU as you scale&lt;/td&gt;
&lt;td&gt;Bundled with platform usage&lt;/td&gt;
&lt;td&gt;Your engineering + incident time&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few notes from using these rather than reading their marketing. Auth0's strength is also its complexity: its Actions/Rules pipeline is powerful but proprietary, so heavy customization is exactly what makes migrating away painful later. Clerk's developer experience is the best I've used — you can have a real, styled, MFA-capable sign-in flow in an afternoon — but that convenience comes from tightly coupled components, so it's most compelling if you're committed to the React ecosystem. Supabase Auth's underrated advantage is that your users table lives in your own Postgres, so joining identity to application data is a normal SQL query and there's no separate system to reconcile; the trade-off is that it's less turnkey for complex enterprise SSO than Auth0.&lt;/p&gt;

&lt;p&gt;If you want the managed version of enterprise-grade auth with the broadest protocol support, Auth0 is the one that handles SAML, OIDC, and enterprise connections without you assembling them yourself. If you want the fastest path to a polished, secure sign-in flow in a React or Next.js app, Clerk is the one that gives you drop-in components and MFA without building the UI. If your data already lives in Postgres and you want identity to be a normal table you can join against, Supabase Auth is the one that keeps users in your own database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick the provider whose default assumptions match where your data and your stack already are — that's what determines integration pain, more than any feature checklist.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you avoid painful lock-in?
&lt;/h2&gt;

&lt;p&gt;You can't eliminate lock-in, but you can contain it. The pattern that has saved me: never let provider-specific types leak past a thin boundary. Wrap the SDK so your application code only ever sees your own &lt;code&gt;User&lt;/code&gt; shape.&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;// auth.ts — the ONLY file that imports the vendor SDK&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;verifyToken&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="s2"&gt;@vendor/sdk&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;AppUser&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;AppUser&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;verifyToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;claims&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;app_roles&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&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="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;Your routes call &lt;code&gt;getUser&lt;/code&gt;, not the vendor SDK. When you migrate providers, you rewrite one file instead of grepping the whole codebase. The other durable habit: keep authorization (what a user can do) in your own database, not in the provider's roles/permissions system. Providers are good at &lt;em&gt;authentication&lt;/em&gt;; owning &lt;em&gt;authorization&lt;/em&gt; yourself keeps your access model portable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Contain each auth provider behind a single adapter module so switching costs stay a one-file problem instead of a rewrite.&lt;/strong&gt;&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Is it safe to build my own authentication in 2026?
&lt;/h3&gt;

&lt;p&gt;It can be safe, but only if you're prepared to own session management, rate limiting, MFA, account-enumeration protection, and CVE patching indefinitely. For most teams the managed providers are safer because auth is their full-time job and yours is your product.&lt;/p&gt;

&lt;h3&gt;
  
  
  Auth0 vs Clerk: which should I choose?
&lt;/h3&gt;

&lt;p&gt;Choose Clerk if you're building a React or Next.js app and want the fastest polished sign-in experience with minimal UI work. Choose Auth0 if you need broad enterprise protocol support like SAML and OIDC connections or expect complex compliance requirements.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Supabase Auth good enough for production?
&lt;/h3&gt;

&lt;p&gt;Yes, especially if your data already lives in Postgres, since users become a table you can join against directly. Its main limitation is that advanced enterprise SSO is less turnkey than Auth0, so evaluate it against your specific SSO roadmap.&lt;/p&gt;

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

&lt;p&gt;Buy your authentication unless you have a concrete reason not to. Choose Auth0 when enterprise protocol breadth and compliance drive the decision, Clerk when developer experience and a React/Next.js sign-in flow matter most, and Supabase Auth when Postgres is already your center of gravity and you want identity to be just another table. Roll your own only when an unusual identity model or a hard constraint genuinely rules the managed options out — and go in knowing you've signed up to operate a security service, not to write a login form. Whatever you pick, hide it behind a one-file adapter and keep authorization in your own database, so the decision stays reversible.&lt;/p&gt;

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

&lt;ul&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/pgvector-vs-pinecone-vs-qdrant-when-is-a-dedicated-vector-database-actually-worth-it-2d3o"&gt;pgvector vs Pinecone vs Qdrant: When Is a Dedicated Vector Database Actually Worth It?&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/libme/the-solo-developers-2026-stack-whats-worth-paying-for-and-what-to-self-host-26g7"&gt;The Solo Developer's 2026 Stack: What's Worth Paying For and What to Self-Host&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>security</category>
      <category>saas</category>
      <category>backend</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
