<?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: Sayah Mahfoud Abd El Ali</title>
    <description>The latest articles on DEV Community by Sayah Mahfoud Abd El Ali (@m4hf0d).</description>
    <link>https://dev.to/m4hf0d</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%2F4114419%2Fe4d82ec8-7c64-4ac6-b1e9-07893ab14d45.jpg</url>
      <title>DEV Community: Sayah Mahfoud Abd El Ali</title>
      <link>https://dev.to/m4hf0d</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/m4hf0d"/>
    <language>en</language>
    <item>
      <title>When a successful restore isn't: a Postgresql upgrade</title>
      <dc:creator>Sayah Mahfoud Abd El Ali</dc:creator>
      <pubDate>Mon, 07 Sep 2026 17:46:47 +0000</pubDate>
      <link>https://dev.to/m4hf0d/when-a-successful-restore-isnt-a-postgresql-upgrade-kl9</link>
      <guid>https://dev.to/m4hf0d/when-a-successful-restore-isnt-a-postgresql-upgrade-kl9</guid>
      <description>&lt;h2&gt;
  
  
  When a successful restore isn't: a Postgresql upgrade
&lt;/h2&gt;

&lt;p&gt;A psql restore streamed an entire dump into a fresh PostgreSQL 17 database, finished, and returned exit code 0. Every signal the tool gives said it worked. The row counts said otherwise.&lt;/p&gt;

&lt;p&gt;This happened during a routine PostgreSQL 15 to 17 major-version upgrade on a self-managed Docker Swarm VPS running a Django stack, real infrastructure with real data in a staging environment. &lt;/p&gt;

&lt;h3&gt;
  
  
  Why dump and restore, not &lt;code&gt;pg_upgrade&lt;/code&gt;:
&lt;/h3&gt;

&lt;p&gt;Postgres 17 engine won't start against a version 15 data directory. That forces a choice of a migration method, and I went with a logical dump and restore over in place &lt;code&gt;pg_upgrade&lt;/code&gt; because dump and restore rebuilds every index fresh on the new version, which avoids a &lt;a href="https://www.crunchydata.com/blog/postgres-migration-pitstop-collations" rel="noopener noreferrer"&gt;glibc collation-version mismatch&lt;/a&gt; , indexes built under an older collation library can silently sort incorrectly after the swap. It also never touches the source volume, so the data directory sits untouched the entire time, and rollback is a single service update rather than a recovery procedure.&lt;/p&gt;

&lt;p&gt;The trade is downtime for the length of the dump plus restore. Acceptable for a database this size. Not a decision I'd make the same way at a few terabytes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sequencing the cutover:
&lt;/h3&gt;

&lt;p&gt;Credentials are Docker secrets mounted as files, not environment variables, so every command below reads the database name and user out of &lt;code&gt;/run/secrets/&lt;/code&gt;. First, stop writes entirely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker service scale &lt;span class="nv"&gt;app_web&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
docker service ps app_web &lt;span class="c"&gt;# to confirm zero running tasks&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then dump version 15 while nothing can write to it:&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="nv"&gt;DBC&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker ps &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app_db | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 1&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;DB_USER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&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;$DBC&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; /run/secrets/db_user&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;DB_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&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;$DBC&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; /run/secrets/db_name&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;DUMP_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"dump_&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y-%m-%d_%H-%M&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.sql"&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;$DBC&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; pg_dump &lt;span class="nt"&gt;-U&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DB_USER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DB_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--no-owner&lt;/span&gt; &lt;span class="nt"&gt;--no-privileges&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DUMP_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 2 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DUMP_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;  &lt;span class="c"&gt;# must end: -- PostgreSQL database dump complete&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That file is now also the backup of record for the whole operation. Then the database service moves to the new image on a new volume, while the web service stays at zero so nothing can &lt;code&gt;migrate&lt;/code&gt; into an empty database prematurely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker volume create app_pgdata_v17
docker service update &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--image&lt;/span&gt; postgres:17-bookworm@sha256:5c34b355088846dddc8afb7442c20b9433dccdc8d66192dc52c616adeaa106a3&lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--mount-rm&lt;/span&gt; /var/lib/postgresql/data &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--mount-add&lt;/span&gt; &lt;span class="nb"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;volume,source&lt;span class="o"&gt;=&lt;/span&gt;app_pgdata_v17,target&lt;span class="o"&gt;=&lt;/span&gt;/var/lib/postgresql/data &lt;span class="se"&gt;\&lt;/span&gt;
  app_db
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What was actually happening :
&lt;/h3&gt;

&lt;p&gt;My first pass waited a fixed number of seconds after that service update, on the assumption that Postgres 17 would have finished initialising by then, and started the restore. It ran to completion. Exit 0. And the row counts afterward showed only seed data, meaning no data was inserted from the dump file.&lt;/p&gt;

&lt;p&gt;The mechanism, from what i found : &lt;code&gt;docker service ps&lt;/code&gt; reported the new task as &lt;code&gt;Running&lt;/code&gt;, and I treated that as my signal to restore. It isn't the right signal. &lt;code&gt;Running&lt;/code&gt; tells you the container process started. It tells you nothing about whether Postgres inside it is actually ready to serve. &lt;/p&gt;

&lt;p&gt;That ruled out the obvious suspects fast: it wasn't a bad dump (the file was intact, verified against &lt;code&gt;pg_dump&lt;/code&gt;'s own completion marker), and it wasn't a wrong-container problem (the restore explicitly targeted the container the service update had just created). What was left was a timing problem between two things that only look synchronous from outside: the container starting, and Postgres inside it becoming ready. On a fresh volume, &lt;code&gt;initdb&lt;/code&gt; has to build the cluster from scratch before Postgres is actually up, and my best explanation, consistent with how the official image bootstraps a new volume, is that the restore landed while that initialization was still settling into the serving data directory, so the writes committed against a state that got superseded a moment later. I don't have logs from that exact window to confirm that specific handoff, but I confirmed the diagnosis itself is correct: I dropped the database, reran the identical restore gated on &lt;code&gt;pg_isready&lt;/code&gt; instead of a fixed wait, and it held.&lt;/p&gt;

&lt;p&gt;A fixed wait cannot fix a race whose duration you don't control and can't observe from outside. What actually closes it is blocking on readiness directly, and re-resolving the container on every check, because the container id itself changes while the Swarm task settles into place:&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="k"&gt;until&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NEWDBC&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;$NEWDBC&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; pg_isready &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;2
    &lt;span class="nv"&gt;NEWDBC&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker ps &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app_db | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 1&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="c"&gt;# re-resolve container ID while Swarm reschedules &lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Polling &lt;code&gt;pg_isready&lt;/code&gt; instead of guessing a duration is the obvious half of the fix. Caching the container id once, before the loop starts, is the trap: in an orchestrator like docker swarm that is actively rescheduling the task, the id you captured might be stale by the time the is_ready check finally passes, and you exec a command into a container that Swarm has already replaced.&lt;/p&gt;

&lt;p&gt;With that loop in place, the restore runs, and it's told to fail loudly rather than silently half-load:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NEWDBC&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; psql &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;-U&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DB_USER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DB_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &amp;lt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DUMP_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once that happened, exit code 0 stopped being sufficient evidence for anything. Every subsequent run verified real rows before letting the application back online and counted against the dump file itself instead of only exit codes:&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;# rows for one table, counted straight out of the dump&lt;/span&gt;
&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s1"&gt;'/^COPY public.auth_user /,/^\\\.$/p'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DUMP_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'1d;$d'&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;

&lt;span class="nv"&gt;NEWDBC&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;docker ps &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;app_db | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# rows live&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;$NEWDBC&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; psql &lt;span class="nt"&gt;-U&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DB_USER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DB_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"SELECT count(*) FROM auth_user;"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two checkpoints. One before the application comes back, one after it comes back and the entrypoint runs &lt;code&gt;migrate&lt;/code&gt;, which should report nothing to apply since the dump already carried &lt;code&gt;django_migrations&lt;/code&gt;. If the counts differ between the two checkpoints, something in the startup path is mutating data, and that's a separate problem to chase down before calling the upgrade finished...&lt;/p&gt;

&lt;h2&gt;
  
  
  The other thing that could have destroyed this
&lt;/h2&gt;

&lt;p&gt;None of the above was the only way to lose data that day. The repository's stack definition still declared &lt;code&gt;postgres:15&lt;/code&gt; pointed at the old volume, and the CI pipeline redeploys automatically on merge to the deploying branch. Which means for the entire duration of this manual cutover, an unrelated merge by anyone, for any reason, would have triggered a deploy that reverted the running database to 15 against the old volume, discarding everything the upgrade had just written.&lt;/p&gt;

&lt;p&gt;The CICD pipeline that exists to make deploys safe becomes the most dangerous thing in the system the moment declared state and running state disagree, so i froze merges before the cutover starts, and once the upgrade is confirmed good, update the repository's image and volume references and let that merge run as a no-op deploy that simply confirms what's already true on the box. Only then is it safe to lift the freeze.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rollback cost nothing, by construction
&lt;/h2&gt;

&lt;p&gt;Because the version 15 volume was never modified, at any point before that repository merge, undoing the whole thing was one command: point the database service back at the &lt;strong&gt;old image&lt;/strong&gt; and &lt;strong&gt;old volume&lt;/strong&gt;, scale the web service back up. Nothing in git to revert, because the repository change was deliberately the last step, done only after the upgrade was already verified. The upgrade was safe to attempt precisely because the previous good state never stopped existing&lt;/p&gt;

&lt;h2&gt;
  
  
  What this is actually about
&lt;/h2&gt;

&lt;p&gt;Postgres major-version upgrades are not rare or exotic. The specific trap is that, a container-swap operation completing successfully from the caller's point of view while the underlying state hasn't caught up yet, is not specific to Postgres either. It shows up &lt;strong&gt;anywhere an orchestrator swaps state out from under a process&lt;/strong&gt; that assumes synchronous readiness: volume mounts, service updates, anything that treats "the command returned" as equivalent to "the system is in the state I asked for." The fix pattern generalizes too: block on an explicit readiness signal, never on elapsed time, and never trust a cached reference to something the orchestrator is free to replace mid-operation.&lt;/p&gt;

&lt;p&gt;The cutover itself is still a manual, human-run sequence with a maintenance window, not a scripted and rehearsed one-command migration. For a database this size, upgraded this infrequently, that's a reasonable place to be. If either of those changes, the next version of this is a script with the verification counts built in as hard gates, not steps I remember to run by hand.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>migration</category>
      <category>devops</category>
      <category>django</category>
    </item>
  </channel>
</rss>
