<?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: Bulgasari</title>
    <description>The latest articles on DEV Community by Bulgasari (@bulgasari).</description>
    <link>https://dev.to/bulgasari</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%2F4168114%2F4c67db94-f4a9-4c30-b667-5237ab1b1087.png</url>
      <title>DEV Community: Bulgasari</title>
      <link>https://dev.to/bulgasari</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bulgasari"/>
    <language>en</language>
    <item>
      <title>The restore that dropped our staging tables: a foreign key nobody restored</title>
      <dc:creator>Bulgasari</dc:creator>
      <pubDate>Wed, 07 Oct 2026 07:26:45 +0000</pubDate>
      <link>https://dev.to/bulgasari/the-restore-that-dropped-our-staging-tables-a-foreign-key-nobody-restored-559c</link>
      <guid>https://dev.to/bulgasari/the-restore-that-dropped-our-staging-tables-a-foreign-key-nobody-restored-559c</guid>
      <description>&lt;p&gt;On 2026-09-28 I restored a dump over a staging MySQL database with my own tool, TunnelForge. The restore stopped partway with this error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR 6125 (HY000): Failed to add the foreign key constraint.
Missing unique key for constraint '...' in the referenced table '...'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That error alone would have cost me a retry. But the import mode I used had already dropped every selected table up front. It then created and loaded them one by one, and it stopped on the first error with no rollback. At that point every selected table it had not reloaded yet was gone.&lt;/p&gt;

&lt;p&gt;The source dump was intact, so I could restore the data again. A later restore into an isolated database loaded every table, though attributes the old dump never recorded could not come back. The failure mode does not depend on my tool. Any restore that drops and recreates tables can walk into it.&lt;/p&gt;

&lt;h2&gt;
  
  
  A table the dump does not know about
&lt;/h2&gt;

&lt;p&gt;Staging databases drift. Someone adds a table there that production does not have, and that table has a foreign key into a table you are about to restore.&lt;/p&gt;

&lt;p&gt;In my case the target-only foreign key did not point at a primary key. It pointed at a column covered by a secondary &lt;code&gt;UNIQUE&lt;/code&gt; key, which MySQL allows. The restore recreated the parent table with only its primary key and planned to add the &lt;code&gt;UNIQUE&lt;/code&gt; key after loading the data, a common way to make loads faster.&lt;/p&gt;

&lt;p&gt;On &lt;code&gt;CREATE TABLE&lt;/code&gt;, MySQL re-binds any existing foreign keys that reference that table name, even with &lt;code&gt;foreign_key_checks = 0&lt;/code&gt;. If the key they need is not there yet, &lt;code&gt;CREATE TABLE&lt;/code&gt; fails. By then the old parent table is already dropped.&lt;/p&gt;

&lt;h2&gt;
  
  
  A minimal repro
&lt;/h2&gt;

&lt;p&gt;This reproduces on stock MySQL 8.0 and 8.4 servers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&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="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;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="n"&gt;uk_users_email&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="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ENGINE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;InnoDB&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- A table that exists only in the target, with an FK to a non-PK UNIQUE column&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;audit_log&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_email&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&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="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;fk_audit_user_email&lt;/span&gt; &lt;span class="k"&gt;FOREIGN&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;users&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="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ENGINE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;InnoDB&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- What a restore of "users" does:&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;foreign_key_checks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;              &lt;span class="c1"&gt;-- succeeds&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;           &lt;span class="c1"&gt;-- fails: the UNIQUE key would only come after loading&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&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="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ENGINE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;InnoDB&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On MySQL 8.4 the last statement fails with &lt;code&gt;ERROR 6125 ... Missing unique key for constraint 'fk_audit_user_email'&lt;/code&gt;. On 8.0 the same script fails with &lt;code&gt;ERROR 1822 ... Missing index for constraint&lt;/code&gt;. Either way, &lt;code&gt;users&lt;/code&gt; is gone and &lt;code&gt;audit_log&lt;/code&gt; is still there, pointing at nothing.&lt;/p&gt;

&lt;p&gt;If you put the &lt;code&gt;UNIQUE KEY&lt;/code&gt; inside the &lt;code&gt;CREATE TABLE&lt;/code&gt;, the same sequence succeeds and &lt;code&gt;audit_log&lt;/code&gt; keeps its row.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it hurt so much
&lt;/h2&gt;

&lt;p&gt;The error concerned one parent table. The order of operations turned it into a much bigger loss:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Drop every selected table.&lt;/li&gt;
&lt;li&gt;Create and load them one at a time.&lt;/li&gt;
&lt;li&gt;Stop at the first error.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With that order, any failure in step 2 leaves the target missing every selected table the restore had not reached yet. My blast radius was the whole selection.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lessons that apply to any restore
&lt;/h2&gt;

&lt;p&gt;Run a preflight before you change anything. Before dropping a table, look for foreign keys outside the restore set that reference it (&lt;code&gt;information_schema.KEY_COLUMN_USAGE&lt;/code&gt; / &lt;code&gt;REFERENTIAL_CONSTRAINTS&lt;/code&gt; on MySQL). For each one, check that the table you are about to create will have a matching primary key or full-column &lt;code&gt;UNIQUE&lt;/code&gt; key at &lt;code&gt;CREATE TABLE&lt;/code&gt; time. If not, refuse with a clear message while the target is still untouched.&lt;/p&gt;

&lt;p&gt;Keep the blast radius to one table. If you must restore in place, do it the way &lt;code&gt;mysqldump&lt;/code&gt; output does: for each table, &lt;code&gt;DROP&lt;/code&gt;, &lt;code&gt;CREATE&lt;/code&gt;, load, then move on. A failure then costs you the table in progress, not everything after it.&lt;/p&gt;

&lt;p&gt;Better still, restore into a new, empty schema instead of in place. Nothing else depends on that schema, so you can drop and recreate tables there as you like.&lt;/p&gt;

&lt;p&gt;Verify before you swap. Once the new copy is loaded, compare it with the dump: row counts, plus content checksums and schema objects if you can. Only then swap it in under the original name, and keep the old data under a backup name instead of deleting it. On MySQL a multi-table &lt;code&gt;RENAME TABLE&lt;/code&gt; is atomic, so the swap is one statement.&lt;/p&gt;

&lt;p&gt;Also keep a backup of the target you are about to overwrite. The steps above do not replace it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What TunnelForge does now
&lt;/h2&gt;

&lt;p&gt;TunnelForge is an open-source (MIT) desktop app for MySQL and PostgreSQL behind SSH bastions. Its dump/import runs in a Rust core. After the incident I made these changes.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Safe restore is the default. It restores the whole dump into a new namespace, verifies rows, values (streamed SHA-256 digests) and schema, and leaves the existing database unchanged while it does that.&lt;/li&gt;
&lt;li&gt;Overwrite runs safe restore first, then swaps the verified copy in under the original name. On MySQL the swap is one atomic multi-table &lt;code&gt;RENAME&lt;/code&gt;. The replaced data stays under a backup name. If the swap plan is not clearly safe, it falls back to asking you.&lt;/li&gt;
&lt;li&gt;Replace is still there as an advanced option for people who want &lt;code&gt;mysqldump&lt;/code&gt; behavior, but it now works table by table: drop, create, load. A failure leaves at most the table in progress missing or partial. Tables it had not reached keep their data, but can lose foreign keys to parents that were already replaced. The result lists the lost tables, the untouched tables, and the foreign keys removed from untouched tables.&lt;/li&gt;
&lt;li&gt;A surviving-FK preflight runs before any table is dropped. Foreign keys from tables outside the restore set (including other schemas) must reference a primary key or full-column &lt;code&gt;UNIQUE&lt;/code&gt; key that exists at create time, and MySQL &lt;code&gt;UNIQUE&lt;/code&gt; keys are now created inside &lt;code&gt;CREATE TABLE&lt;/code&gt;. Otherwise TunnelForge refuses the import and changes nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These checks run in CI against live MySQL and PostgreSQL servers on every pull request. The new MySQL tests fail on the old code and pass on the new code.&lt;/p&gt;

&lt;p&gt;The code is on &lt;a href="https://github.com/sanghyun-io/tunnelforge" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;, and &lt;code&gt;docs/export_import_policy.md&lt;/code&gt; explains how each import mode behaves.&lt;/p&gt;

&lt;p&gt;If you restore over shared databases, check what else points at the tables you are about to drop before you drop them.&lt;/p&gt;

</description>
      <category>mysql</category>
      <category>database</category>
      <category>backup</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
