<?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: Trevor Ogina</title>
    <description>The latest articles on DEV Community by Trevor Ogina (@trevor_ogina_08029f4ee083).</description>
    <link>https://dev.to/trevor_ogina_08029f4ee083</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%2F4235100%2F2f217743-f2fa-4bc3-b5d4-bf1290f68c74.png</url>
      <title>DEV Community: Trevor Ogina</title>
      <link>https://dev.to/trevor_ogina_08029f4ee083</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/trevor_ogina_08029f4ee083"/>
    <language>en</language>
    <item>
      <title>Durable AI agents: LangGraph with Postgres checkpointing and a human approval gate</title>
      <dc:creator>Trevor Ogina</dc:creator>
      <pubDate>Mon, 09 Nov 2026 12:23:27 +0000</pubDate>
      <link>https://dev.to/trevor_ogina_08029f4ee083/durable-ai-agents-langgraph-with-postgres-checkpointing-and-a-human-approval-gate-2k73</link>
      <guid>https://dev.to/trevor_ogina_08029f4ee083/durable-ai-agents-langgraph-with-postgres-checkpointing-and-a-human-approval-gate-2k73</guid>
      <description>&lt;p&gt;Most agent demos keep their state in memory and act without asking anyone. That's fine for a notebook, but it isn't acceptable when the agent can send an email or submit something on a user's behalf.&lt;/p&gt;

&lt;p&gt;When I built a multi-stage automation agent in Python, I needed two properties:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Durability&lt;/strong&gt;: if the process restarts halfway through a run, the run picks up where it left off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A human gate&lt;/strong&gt;: before any step with an external side effect, the agent stops and waits for a person to approve.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;LangGraph provides both, through &lt;strong&gt;checkpointers&lt;/strong&gt; and &lt;strong&gt;interrupts&lt;/strong&gt;. Here is the minimal version of the pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  The graph
&lt;/h2&gt;

&lt;p&gt;Three nodes: draft something, wait for approval, then act on it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TypedDict&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langgraph.graph&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;StateGraph&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;START&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;END&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langgraph.types&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;interrupt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Command&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;State&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TypedDict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;draft&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;approved&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;draft_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# call your LLM here; validate its output with Pydantic before trusting it
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;draft&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Proposed action for: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;task&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;approval_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# pauses the run and hands the draft back to the caller
&lt;/span&gt;    &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;interrupt&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;draft&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;draft&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;question&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Approve?&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="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approved&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;decision&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approve&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;act_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approved&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="p"&gt;...&lt;/span&gt;  &lt;span class="c1"&gt;# the only place with external side effects
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;


&lt;span class="n"&gt;builder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;StateGraph&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;State&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;draft&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;draft_node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approval&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;approval_node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;act&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;act_node&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_edge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;START&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;draft&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_edge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;draft&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approval&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_edge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approval&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;act&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_edge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;act&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;END&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule that matters: &lt;strong&gt;side effects live in exactly one node, and that node is after the gate.&lt;/strong&gt; Everything before the gate can safely run again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Durable state with Postgres
&lt;/h2&gt;

&lt;p&gt;Compile the graph with a Postgres checkpointer, and every step's state is saved under a &lt;code&gt;thread_id&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;langgraph.checkpoint.postgres&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;PostgresSaver&lt;/span&gt;

&lt;span class="n"&gt;DB_URI&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgresql://user:pass@localhost:5432/agents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;PostgresSaver&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_conn_string&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DB_URI&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;checkpointer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;checkpointer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setup&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# creates the checkpoint tables once
&lt;/span&gt;    &lt;span class="n"&gt;graph&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;checkpointer&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;checkpointer&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;configurable&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;thread_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;job-1234&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;task&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;apply to job 1234&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# the run stops at interrupt(); the result carries the interrupt payload
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the process can die, and so can the whole machine. Calling the graph again with the same &lt;code&gt;thread_id&lt;/code&gt; loads the latest checkpoint from Postgres.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resuming after a human decides
&lt;/h2&gt;

&lt;p&gt;The approval can come from anywhere: a web UI, a chat message, a CLI prompt. Resume the same thread with the decision:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;graph&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Command&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resume&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approve&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The run continues from the &lt;code&gt;interrupt()&lt;/code&gt; call. &lt;code&gt;approval_node&lt;/code&gt; receives &lt;code&gt;"approve"&lt;/code&gt; as the return value, and &lt;code&gt;act_node&lt;/code&gt; runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned running this for real
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use one &lt;code&gt;thread_id&lt;/code&gt; per business object&lt;/strong&gt; (a job, a ticket, an order), not per process. Resuming is then just a lookup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate LLM output before it reaches state.&lt;/strong&gt; A Pydantic model on each draft stops malformed output from spreading through the graph.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a verifier step for generated content.&lt;/strong&gt; I score drafts against the source facts and reject anything unsupported before it ever reaches the human gate, so reviewers spend their time on judgement, not on catching invented details.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Track tokens per thread.&lt;/strong&gt; Logging token usage against the &lt;code&gt;thread_id&lt;/code&gt; makes the cost of each business object visible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep nodes idempotent where you can.&lt;/strong&gt; Checkpointing protects state, but a node that crashes partway can still re-run its side effects. Put the risky work after the gate and make it safe to repeat.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why it matters
&lt;/h2&gt;

&lt;p&gt;Durability and human gates turn an agent from a demo into something you can run in production. Postgres is usually already in the stack, so you get durable state with no new infrastructure, and the interrupt pattern means you decide where a human must sign off.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I'm Trevor Ogina, a senior backend and cloud engineer in Nairobi building Go, Spring Boot and Python systems, and the LLM agents on top of them. &lt;a href="https://github.com/Trevor-crypto243" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://www.linkedin.com/in/trevor-ogina-150937213" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>langgraph</category>
      <category>postgres</category>
    </item>
    <item>
      <title>How EXPLAIN ANALYZE helped us cut PostgreSQL query time by 80% under peak load</title>
      <dc:creator>Trevor Ogina</dc:creator>
      <pubDate>Mon, 09 Nov 2026 12:19:17 +0000</pubDate>
      <link>https://dev.to/trevor_ogina_08029f4ee083/how-explain-analyze-helped-us-cut-postgresql-query-time-by-80-under-peak-load-14p9</link>
      <guid>https://dev.to/trevor_ogina_08029f4ee083/how-explain-analyze-helped-us-cut-postgresql-query-time-by-80-under-peak-load-14p9</guid>
      <description>&lt;p&gt;On an SME advisory platform I worked on, used by more than 1,000 business owners, the database CPU spiked every time traffic peaked. Real-time advisory sessions lagged, and the first instinct on the team was "we need a bigger database."&lt;/p&gt;

&lt;p&gt;We didn't need one. Rebuilding the indexes around how the application &lt;em&gt;actually&lt;/em&gt; queried data cut query time by &lt;strong&gt;80%&lt;/strong&gt; and removed the CPU spikes. This is the process I used, and it works on most slow-Postgres problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Find the queries that matter
&lt;/h2&gt;

&lt;p&gt;Don't guess. Under load, find out which queries take up the database's time. The &lt;code&gt;pg_stat_statements&lt;/code&gt; extension is the easiest way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;pg_stat_statements&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;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;calls&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="n"&gt;total_exec_time&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;numeric&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;total_ms&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="n"&gt;mean_exec_time&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;numeric&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;AS&lt;/span&gt; &lt;span class="n"&gt;mean_ms&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_statements&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;total_exec_time&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sort by &lt;strong&gt;total&lt;/strong&gt; time, not mean time. A 5 ms query called 200,000 times hurts more than one 2-second report.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Read the plan, not just the timing
&lt;/h2&gt;

&lt;p&gt;Take the worst offender and run it with &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt; against realistic data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;BUFFERS&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;s&lt;/span&gt;&lt;span class="p"&gt;.&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;sessions&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;advisor_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&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;'scheduled'&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;starts_at&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What to look for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Seq Scan&lt;/code&gt; on a large table&lt;/strong&gt; inside a hot path: Postgres is reading every row because no index fits the query.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Estimated rows far from actual rows&lt;/strong&gt;: stale statistics, so the planner is choosing blind. Run &lt;code&gt;ANALYZE&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Sort&lt;/code&gt; steps on large inputs&lt;/strong&gt;: an index in the right order often removes them entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High &lt;code&gt;shared read&lt;/code&gt; buffers&lt;/strong&gt;: you are going to disk on every request.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In our case, the hot paths were doing sequential scans. The indexes that existed had been designed around an earlier version of the product, and no longer matched how it filtered and sorted.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Design indexes from access patterns
&lt;/h2&gt;

&lt;p&gt;Ask what each hot query &lt;strong&gt;filters on&lt;/strong&gt;, then what it &lt;strong&gt;sorts by&lt;/strong&gt;. For the query above, a composite index in that order does the job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;CONCURRENTLY&lt;/span&gt; &lt;span class="n"&gt;idx_sessions_advisor_status_start&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;sessions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;advisor_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;starts_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two rules worth remembering:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Equality columns first, range or sort columns last.&lt;/strong&gt; &lt;code&gt;(advisor_id, status, starts_at)&lt;/code&gt; serves &lt;code&gt;WHERE advisor_id = ? AND status = ? ORDER BY starts_at&lt;/code&gt; without a separate sort.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;CONCURRENTLY&lt;/code&gt; in production.&lt;/strong&gt; It builds the index without blocking writes. It is slower, and it cannot run inside a transaction.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If most queries only touch a subset of rows, such as active records, a &lt;strong&gt;partial index&lt;/strong&gt; is smaller and faster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;CONCURRENTLY&lt;/span&gt; &lt;span class="n"&gt;idx_sessions_scheduled&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;sessions&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;advisor_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;starts_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'scheduled'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Verify with the plan again
&lt;/h2&gt;

&lt;p&gt;Re-run &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt;. You want to see an &lt;code&gt;Index Scan&lt;/code&gt; or &lt;code&gt;Index Only Scan&lt;/code&gt;, far fewer buffers read, and the sort step gone. Then compare &lt;code&gt;pg_stat_statements&lt;/code&gt; before and after under similar load.&lt;/p&gt;

&lt;p&gt;Don't forget the other side of the trade: every index makes writes slower and takes disk space. Look for indexes nobody uses and drop them:&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;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;indexrelname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;idx_scan&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_user_indexes&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;idx_scan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;relname&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. Stop it coming back
&lt;/h2&gt;

&lt;p&gt;Our last step mattered most over time: &lt;strong&gt;we added load testing to CI.&lt;/strong&gt; Performance regressions now fail a build instead of surfacing in production. A query that is fine on a developer laptop can still fall over at peak, and this catches it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Measure with &lt;code&gt;pg_stat_statements&lt;/code&gt; and sort by total time.&lt;/li&gt;
&lt;li&gt;Read plans with &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Build composite or partial indexes from real filter and sort patterns, &lt;code&gt;CONCURRENTLY&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Verify with the plan, and drop indexes nobody uses.&lt;/li&gt;
&lt;li&gt;Put load testing in CI.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It's rarely the most exciting fix, but it often beats a bigger instance, and it costs nothing per month.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I'm Trevor Ogina, a senior backend and cloud engineer in Nairobi. I lead teams building Go and Spring Boot platforms on Azure, AWS and GCP. &lt;a href="https://github.com/Trevor-crypto243" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; · &lt;a href="https://www.linkedin.com/in/trevor-ogina-150937213" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>performance</category>
      <category>backend</category>
    </item>
    <item>
      <title>We were asked to migrate a production AWS estate. We transferred the account instead, with zero downtime</title>
      <dc:creator>Trevor Ogina</dc:creator>
      <pubDate>Mon, 09 Nov 2026 12:18:25 +0000</pubDate>
      <link>https://dev.to/trevor_ogina_08029f4ee083/we-were-asked-to-migrate-a-production-aws-estate-we-transferred-the-account-instead-with-zero-1el7</link>
      <guid>https://dev.to/trevor_ogina_08029f4ee083/we-were-asked-to-migrate-a-production-aws-estate-we-transferred-the-account-instead-with-zero-1el7</guid>
      <description>&lt;p&gt;A multi-country education non-profit asked us to move its data portal off a third-party provider's AWS account and onto infrastructure it controlled. The portal had 2,500+ users in more than 10 countries, and it was in the middle of a programme cycle. "Take it down for a weekend" was not an option.&lt;/p&gt;

&lt;p&gt;The brief said &lt;em&gt;migration&lt;/em&gt;. We ended up recommending something else, and the reason is the most useful lesson I took from the engagement: &lt;strong&gt;do read-only discovery before you choose the migration strategy, not after.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: read-only discovery
&lt;/h2&gt;

&lt;p&gt;Before proposing anything, we got read-only access to the live account and mapped what was actually running:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;compute, databases and storage, and how they were wired together;&lt;/li&gt;
&lt;li&gt;IAM: who and what had access, and how;&lt;/li&gt;
&lt;li&gt;networking and DNS: where traffic entered and who controlled the records;&lt;/li&gt;
&lt;li&gt;backups, monitoring and alerting: what existed, and what we would lose if we moved things.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Read-only matters. Nobody can accidentally break production while you are still learning how it works, and the provider is far more comfortable granting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: the finding that changed the plan
&lt;/h2&gt;

&lt;p&gt;The obvious plan was a classic lift-and-shift. Stand up a new account, replicate the databases, copy the storage, rebuild the services, then cut DNS over during a maintenance window.&lt;/p&gt;

&lt;p&gt;Discovery showed that &lt;strong&gt;DNS control sat inside the source account itself.&lt;/strong&gt; That changed everything. A data migration now meant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a cutover window that every user would feel;&lt;/li&gt;
&lt;li&gt;recreating every component, and its configuration drift, in a new account, with regression risk at each step;&lt;/li&gt;
&lt;li&gt;coordinating a DNS change with the very provider we were moving away from.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We had another option. AWS lets you move an account from one organization to another, so the account itself, with everything already running in it, could change hands. Nothing moves and nothing gets rebuilt. What changes is who owns and controls it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: weighing the trade-off
&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;Data migration&lt;/th&gt;
&lt;th&gt;Account ownership transfer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Downtime&lt;/td&gt;
&lt;td&gt;Cutover window&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Regression risk&lt;/td&gt;
&lt;td&gt;Every rebuilt component&lt;/td&gt;
&lt;td&gt;Minimal (nothing rebuilt)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clean greenfield setup&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No: you inherit the existing setup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Effort&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Low to medium&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The cost of the transfer is real: you inherit whatever is already in the account, good and bad. We judged that a fair trade, because the inherited problems could be fixed &lt;em&gt;after&lt;/em&gt; the client owned the account, on the client's own schedule, without any user-facing risk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: plan it like a migration anyway
&lt;/h2&gt;

&lt;p&gt;Choosing the low-risk path did not mean skipping the paperwork. We still wrote:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;An architecture baseline&lt;/strong&gt;: what exists today, documented, so the client knows what it is taking ownership of.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A migration strategy&lt;/strong&gt;: why the transfer beat the alternatives, in plain language for non-technical stakeholders.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A week-by-week runbook, with an hour-by-hour plan for transfer day&lt;/strong&gt;: who does what, in what order, with checkpoints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A risk register&lt;/strong&gt;: each risk, its likelihood and impact, the mitigation, and an owner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A provider handover questionnaire&lt;/strong&gt;: everything the outgoing provider knew that was not written down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A target-account specification&lt;/strong&gt;: how billing, IAM and access should look once the client owned it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 5: sort security findings into "fix now" and "fix after"
&lt;/h2&gt;

&lt;p&gt;Discovery always turns things up. We found security exposures that needed attention. Fixing all of them in the middle of an ownership change would have stacked risk on risk, so we sorted them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;fix now&lt;/strong&gt;: anything dangerous enough that it could not wait for the transfer;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;fix after&lt;/strong&gt;: everything else, scheduled once the client was in control.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because the client's users were in several countries, we also flagged the data-residency side, including the Kenyan and Nigerian data-protection laws.&lt;/p&gt;

&lt;h2&gt;
  
  
  The result
&lt;/h2&gt;

&lt;p&gt;The transfer completed with &lt;strong&gt;zero downtime&lt;/strong&gt;. Users noticed nothing, no data moved, and the client ended up with full control of billing, IAM and DNS. The remediation work then went ahead on a calm schedule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Discovery picks the strategy.&lt;/strong&gt; We only found the right plan because we looked before committing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask "what if nothing has to move?"&lt;/strong&gt; Moving ownership is often cheaper and safer than moving data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Low risk still needs a runbook.&lt;/strong&gt; The documents are what make a calm transfer day possible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split remediation by urgency&lt;/strong&gt; so you don't add security work to a risky change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;I'm Trevor Ogina, a senior backend and cloud engineer in Nairobi. I lead teams building Go and Spring Boot platforms on Azure, AWS and GCP. You can find me on &lt;a href="https://github.com/Trevor-crypto243" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt; and &lt;a href="https://www.linkedin.com/in/trevor-ogina-150937213" rel="noopener noreferrer"&gt;LinkedIn&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>cloud</category>
      <category>migration</category>
    </item>
  </channel>
</rss>
