<?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: Son Tran</title>
    <description>The latest articles on DEV Community by Son Tran (@tbson87).</description>
    <link>https://dev.to/tbson87</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%2F79995%2F6de9d37a-5521-4b7a-a835-9787e74caf0c.jpg</url>
      <title>DEV Community: Son Tran</title>
      <link>https://dev.to/tbson87</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tbson87"/>
    <language>en</language>
    <item>
      <title>How to Track Schema Changes in PostgreSQL: DDL Logging, an Event Trigger or a Schema Snapshot</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Fri, 23 Oct 2026 15:15:10 +0000</pubDate>
      <link>https://dev.to/tbson87/how-to-track-schema-changes-in-postgresql-ddl-logging-an-event-trigger-or-a-schema-snapshot-5g91</link>
      <guid>https://dev.to/tbson87/how-to-track-schema-changes-in-postgresql-ddl-logging-an-event-trigger-or-a-schema-snapshot-5g91</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://schemity.com" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt;, a desktop ERD tool - this post is from our blog and uses it for the examples.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Pick by the question you need answered. &lt;code&gt;log_statement = 'ddl'&lt;/code&gt; records what was attempted, rolled-back statements included, and misses DDL run inside a &lt;code&gt;DO&lt;/code&gt; block or function. An event trigger records committed changes with the login that made them, but only a superuser can install it, and a superuser can switch it off without leaving a trace in it. A schema snapshot compared with the live database shows what is different now, however it got there, but not who did it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There are three ways to track schema changes in PostgreSQL, and they answer three different questions. The server log with &lt;code&gt;log_statement = 'ddl'&lt;/code&gt; records what someone tried to run. An event trigger records what was committed, and by whom. A schema snapshot compared with the live database shows what is different now, whoever made the change and however they made it.&lt;/p&gt;

&lt;p&gt;Each one has a gap the others cover. The log keeps statements that were rolled back and misses DDL run inside a function. The event trigger needs a superuser to install, and a superuser can switch it off for one session. The snapshot cannot tell you who. Every result below was measured on PostgreSQL 18.3 in a throwaway container, against this schema:&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;customers&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;bigint&lt;/span&gt; &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;IDENTITY&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;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&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;orders&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;bigint&lt;/span&gt; &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;IDENTITY&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;customer_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;total&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;12&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;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How to track schema changes in PostgreSQL
&lt;/h2&gt;

&lt;p&gt;The schema change you need to find is rarely the one in your migrations folder. It is the index someone added by hand during an incident, the column a support script created, the &lt;code&gt;ALTER&lt;/code&gt; an AI agent ran against a dev database that was later promoted. Migration tools record which of their files ran; they do not notice a change made around them. Hence the three approaches below, compared on what they catch:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Server log (DDL)&lt;/th&gt;
&lt;th&gt;Event trigger&lt;/th&gt;
&lt;th&gt;Schema snapshot&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Answers&lt;/td&gt;
&lt;td&gt;What was attempted&lt;/td&gt;
&lt;td&gt;What was committed, by whom&lt;/td&gt;
&lt;td&gt;What is different now&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rolled-back DDL&lt;/td&gt;
&lt;td&gt;Logged, with no sign it was undone&lt;/td&gt;
&lt;td&gt;Not recorded&lt;/td&gt;
&lt;td&gt;Not visible (nothing changed)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DDL inside &lt;code&gt;DO&lt;/code&gt; or a function&lt;/td&gt;
&lt;td&gt;Missed&lt;/td&gt;
&lt;td&gt;Recorded&lt;/td&gt;
&lt;td&gt;Visible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Who made the change&lt;/td&gt;
&lt;td&gt;Only with &lt;code&gt;%u&lt;/code&gt; added to &lt;code&gt;log_line_prefix&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;session_user&lt;/code&gt; in the audit row&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Roles, databases, tablespaces&lt;/td&gt;
&lt;td&gt;Logged&lt;/td&gt;
&lt;td&gt;Never fires&lt;/td&gt;
&lt;td&gt;Only with &lt;code&gt;pg_dumpall --globals-only&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Needs&lt;/td&gt;
&lt;td&gt;Superuser to set it&lt;/td&gt;
&lt;td&gt;Superuser to create it&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;SELECT&lt;/code&gt; on the tables it dumps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Can be switched off by&lt;/td&gt;
&lt;td&gt;A superuser&lt;/td&gt;
&lt;td&gt;A superuser, per session or for everyone, unrecorded&lt;/td&gt;
&lt;td&gt;Nobody, but it sees only the state at each snapshot&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For most teams the answer is a snapshot for what changed, plus an event trigger for who did it. The rest of this post is the evidence for each row.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does log_statement = 'ddl' catch every schema change?
&lt;/h2&gt;

&lt;p&gt;It catches every DDL statement a client sends, which is not the same thing. With &lt;code&gt;log_statement = 'ddl'&lt;/code&gt; set, PostgreSQL wrote these lines for this session:&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;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;note&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&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;rolled_back&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;ROLLBACK&lt;/span&gt;&lt;span class="p"&gt;;&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;BEGIN&lt;/span&gt; &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="s1"&gt;'ALTER TABLE orders ADD COLUMN via_do int'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first two appeared as &lt;code&gt;LOG:  statement: ALTER TABLE orders ADD COLUMN ...&lt;/code&gt;. The second was rolled back, and the log says nothing about the rollback: anyone reading the log afterwards sees a &lt;code&gt;rolled_back&lt;/code&gt; column that &lt;code&gt;orders&lt;/code&gt; does not have. The third added a real column, &lt;code&gt;via_do&lt;/code&gt;, and was not logged at all, because the statement the client sent was a &lt;code&gt;DO&lt;/code&gt; block, not DDL. A function that runs &lt;code&gt;EXECUTE 'ALTER TABLE ...'&lt;/code&gt; behaves the same way: the &lt;code&gt;CREATE FUNCTION&lt;/code&gt; is logged, the call that changes the table is not.&lt;/p&gt;

&lt;p&gt;One more limit comes from the &lt;a href="https://www.postgresql.org/docs/18/runtime-config-logging.html" rel="noopener noreferrer"&gt;PostgreSQL logging documentation&lt;/a&gt;: the default &lt;code&gt;log_line_prefix&lt;/code&gt; is &lt;code&gt;'%m [%p] '&lt;/code&gt;, a time and a process ID, so the line does not say which user ran it until you add &lt;code&gt;%u&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The log is still worth turning on. It is one setting, it costs nothing, and for "what happened at 02:14 last night" it is often the only record there is.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you log DDL changes with an event trigger?
&lt;/h2&gt;

&lt;p&gt;An event trigger runs a function after every DDL command, inside the same transaction. This one writes each change to an audit table, and a second trigger catches drops, which &lt;code&gt;pg_event_trigger_ddl_commands()&lt;/code&gt; does not report:&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;ddl_audit&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;bigint&lt;/span&gt; &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;IDENTITY&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;happened_at&lt;/span&gt;  &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;db_user&lt;/span&gt;      &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;session_user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;command_tag&lt;/span&gt;  &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;object_type&lt;/span&gt;  &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;object_name&lt;/span&gt;  &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;statement&lt;/span&gt;    &lt;span class="nb"&gt;text&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;log_ddl&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="n"&gt;event_trigger&lt;/span&gt;
&lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt; &lt;span class="k"&gt;DEFINER&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;search_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pg_catalog&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;AS&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;r&lt;/span&gt; &lt;span class="n"&gt;record&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;FOR&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="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;pg_event_trigger_ddl_commands&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;LOOP&lt;/span&gt;
        &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;ddl_audit&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command_tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;object_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;object_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;statement&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;command_tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_identity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current_query&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;LOOP&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="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;log_drop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="n"&gt;event_trigger&lt;/span&gt;
&lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt; &lt;span class="k"&gt;DEFINER&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;search_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pg_catalog&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;AS&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;r&lt;/span&gt; &lt;span class="n"&gt;record&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;FOR&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="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;pg_event_trigger_dropped_objects&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;LOOP&lt;/span&gt;
        &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;ddl_audit&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command_tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;object_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;object_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;statement&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tg_tag&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_identity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current_query&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;LOOP&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="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EVENT&lt;/span&gt; &lt;span class="k"&gt;TRIGGER&lt;/span&gt; &lt;span class="n"&gt;audit_ddl&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;ddl_command_end&lt;/span&gt; &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;log_ddl&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;EVENT&lt;/span&gt; &lt;span class="k"&gt;TRIGGER&lt;/span&gt; &lt;span class="n"&gt;audit_drop&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;sql_drop&lt;/span&gt; &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;log_drop&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run against the same three statements, the audit table got one row for the committed &lt;code&gt;ALTER&lt;/code&gt;, nothing for the rolled-back one, and a row for the column added inside the &lt;code&gt;DO&lt;/code&gt; block, with the whole &lt;code&gt;DO&lt;/code&gt; block as the statement. An &lt;code&gt;ALTER TABLE orders DROP COLUMN&lt;/code&gt; produced two rows: the dropped column from the &lt;code&gt;sql_drop&lt;/code&gt; trigger and the altered table from &lt;code&gt;ddl_command_end&lt;/code&gt;. A &lt;code&gt;COMMENT ON COLUMN&lt;/code&gt; was recorded too. A &lt;code&gt;DROP TABLE IF EXISTS&lt;/code&gt; on a table that did not exist recorded nothing, which is right, since nothing changed.&lt;/p&gt;

&lt;p&gt;Four things to know before you rely on it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SECURITY DEFINER&lt;/code&gt; is not optional.&lt;/strong&gt; Without it, the audit &lt;code&gt;INSERT&lt;/code&gt; runs as whoever issued the DDL. In the test, a role with &lt;code&gt;CREATE&lt;/code&gt; on the schema but no &lt;code&gt;INSERT&lt;/code&gt; on &lt;code&gt;ddl_audit&lt;/code&gt; ran &lt;code&gt;CREATE TABLE&lt;/code&gt; and got &lt;code&gt;permission denied for table ddl_audit&lt;/code&gt;: the audit trigger blocked the change it was meant to record. With &lt;code&gt;SECURITY DEFINER&lt;/code&gt;, the same &lt;code&gt;CREATE TABLE&lt;/code&gt; succeeded and the row named the user, because &lt;code&gt;session_user&lt;/code&gt; is still the login role inside the function.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only a superuser can create it.&lt;/strong&gt; A non-superuser gets &lt;code&gt;permission denied to create event trigger&lt;/code&gt; with the hint &lt;code&gt;Must be superuser to create an event trigger&lt;/code&gt;, as the &lt;a href="https://www.postgresql.org/docs/18/sql-createeventtrigger.html" rel="noopener noreferrer"&gt;&lt;code&gt;CREATE EVENT TRIGGER&lt;/code&gt; reference&lt;/a&gt; says.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared objects are never seen.&lt;/strong&gt; The &lt;a href="https://www.postgresql.org/docs/18/event-trigger-definition.html" rel="noopener noreferrer"&gt;event trigger documentation&lt;/a&gt; excludes databases, roles, tablespaces, parameter privileges and &lt;code&gt;ALTER SYSTEM&lt;/code&gt;. A &lt;code&gt;CREATE ROLE&lt;/code&gt; left no row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A superuser can step around it.&lt;/strong&gt; &lt;code&gt;SET event_triggers = off&lt;/code&gt;, a setting added in PostgreSQL 17, disables every event trigger for the session, and an &lt;code&gt;ALTER TABLE&lt;/code&gt; run after it left no row. &lt;code&gt;SET session_replication_role = replica&lt;/code&gt; did the same until both triggers were set to &lt;code&gt;ENABLE ALWAYS&lt;/code&gt; (&lt;code&gt;ALTER EVENT TRIGGER audit_ddl ENABLE ALWAYS&lt;/code&gt;, and the same for &lt;code&gt;audit_drop&lt;/code&gt;), after which they fired anyway. &lt;code&gt;ALTER EVENT TRIGGER audit_ddl DISABLE&lt;/code&gt; or a &lt;code&gt;DROP EVENT TRIGGER&lt;/code&gt; turns it off for everyone, and leaves no row either, since event triggers never fire for commands on event triggers. The server log does record those, which is the best argument for running both.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So an event trigger is an honest record of changes made by people who are not trying to hide them. That is most changes, and it is the only one of the three that answers "who", with one caveat: &lt;code&gt;session_user&lt;/code&gt; is the login role. A superuser who runs &lt;code&gt;SET ROLE dev&lt;/code&gt; before a &lt;code&gt;CREATE TABLE&lt;/code&gt; is recorded as the superuser, and a team that shares one migration login gets that login as the answer every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does a pg_dump diff show changes that did not happen?
&lt;/h2&gt;

&lt;p&gt;The snapshot approach is the simplest to set up: dump the schema, commit the file, dump it again later and compare. &lt;code&gt;pg_dump --schema-only&lt;/code&gt; needs no superuser, only &lt;code&gt;SELECT&lt;/code&gt; on the tables it dumps (it takes an &lt;code&gt;ACCESS SHARE&lt;/code&gt; lock on each), and the comparison sees everything that is in the database now, however it got there. Its blind spot is the time between snapshots: a change made and undone in between leaves no trace.&lt;/p&gt;

&lt;p&gt;Since the August 2025 minor releases (17.6, 16.10 and their siblings), two dumps of an unchanged table are not identical. Each begins with a &lt;code&gt;\restrict&lt;/code&gt; line and ends with an &lt;code&gt;\unrestrict&lt;/code&gt; line carrying a random key, a defence against a malicious server injecting psql commands into a dump (&lt;a href="https://www.postgresql.org/docs/release/17.6/" rel="noopener noreferrer"&gt;CVE-2025-8714&lt;/a&gt;, released August 14, 2025). Diffing two dumps of the same &lt;code&gt;orders&lt;/code&gt; table taken a second apart on 18.3 gave a four-line difference and no schema change.&lt;/p&gt;

&lt;p&gt;The fix is &lt;code&gt;pg_dump --schema-only --restrict-key=schema&lt;/code&gt;, which writes a fixed key. The &lt;a href="https://www.postgresql.org/docs/18/app-pgdump.html" rel="noopener noreferrer"&gt;pg_dump documentation&lt;/a&gt; names comparing dump files as a reason to use it, and warns against using a known key for a dump you will restore. With the fixed key, a real change was the only difference: &lt;code&gt;total numeric(12,2)&lt;/code&gt; became &lt;code&gt;total numeric(14,2)&lt;/code&gt;. The &lt;code&gt;-- Dumped from database version&lt;/code&gt; and &lt;code&gt;-- Dumped by pg_dump version&lt;/code&gt; headers still change when the server or the client is upgraded, so expect one noisy diff then.&lt;/p&gt;

&lt;p&gt;What the snapshot cannot do is say who changed the column or when. It says the column is different now. That is usually the question you have when you inherit a database, or come back to one after a month, and it is the one the logs answer worst: you would have to replay every line since the last time you looked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seeing what changed since you last saved the diagram
&lt;/h2&gt;

&lt;p&gt;Schemity is database design software that reads your live database, shows the impact of every schema change before it runs, and keeps the diagram as a file in Git. The saved diagram is a schema snapshot already, so opening it is the comparison. When a connected diagram that has been saved at least once opens, Schemity reads the live database, compares it with the file as you last saved it, and if anything differs, a &lt;strong&gt;Database changed&lt;/strong&gt; button appears in the canvas's top-right corner. A change made through &lt;code&gt;psql&lt;/code&gt;, an ORM's migration or an agent's session shows up there the same way.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fej36zhwjpflwudklils7.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fej36zhwjpflwudklils7.webp" alt="Schemity after reopening a saved PostgreSQL 18.3 diagram whose database was changed in psql: a Database changed button in the canvas's top-right corner, the title bar marked unsaved, the new refunds table with a dashed border below orders, and the dropped legacy_coupons table no longer on the canvas" width="800" height="451"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The button opens the change preview, titled "Changed in the database since you last saved". Every table the database changed gets a border: green for added, red for removed, blue for altered, with removed tables drawn where they used to be, since the diagram itself no longer has them. A table's footer counts what changed in its keys, constraints and indexes, so a hand-made index reads &lt;code&gt;idx: 1 (+1)&lt;/code&gt;, with a bar on the column it covers. Each change is also listed in words, such as &lt;code&gt;Index on orders (status) added&lt;/code&gt;. There is no SQL in it, because nothing is going to run. On a restricted licence tier the same changes come as a plain list instead of the drawing.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl2xgi925jjqm5tocv8cs.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl2xgi925jjqm5tocv8cs.webp" alt="Schemity's change preview of what the database changed since the last save: orders with a blue border, idx: 1 (+1) in its footer and a bar on the status column, refunds with a green border, the dropped legacy_coupons table with a red border where it used to be, and the Findings drawer listing Table legacy_coupons dropped, Table refunds created and Index on orders (status) added" width="800" height="451"&gt;&lt;/a&gt; On the main diagram, tables the database added carry the dashed border of a table added since the last save.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://schemity.com/doc/resync-database/" rel="noopener noreferrer"&gt;What it compares&lt;/a&gt; is what the database owns: tables, columns with their type, nullability and default, primary keys, unique constraints, checks, indexes and declared foreign keys. Positions, colours, descriptions and &lt;a href="https://schemity.com/doc/virtual-relations/" rel="noopener noreferrer"&gt;virtual relations&lt;/a&gt; are the diagram's own and never count as a change. Views, triggers, functions and grants are not part of the comparison, so keep the event trigger for those.&lt;/p&gt;

&lt;p&gt;Nothing is accepted until you save. The merge sits on the diagram as one unsaved change: &lt;strong&gt;Save&lt;/strong&gt; accepts the database's version and the button goes, &lt;strong&gt;Undo&lt;/strong&gt; puts the diagram back to the file. Commit the saved file and &lt;a href="https://schemity.com/doc/version-control-git/" rel="noopener noreferrer"&gt;the diagram's JSON in Git&lt;/a&gt; becomes the dated record of what the schema was at each save, which is the part a snapshot needs and a log never gives you. The same check runs on MySQL, SQL Server and SQLite connections.&lt;/p&gt;

&lt;p&gt;This is what it takes to understand your schema as it is rather than as the migrations say it should be. It does not replace the event trigger: the preview tells you &lt;code&gt;orders&lt;/code&gt; gained an index since Tuesday, and the audit table tells you who added it and what they typed.&lt;/p&gt;

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

&lt;p&gt;A change you find this way still has a cost to undo, and &lt;a href="https://schemity.com/doc/impact-analysis/" rel="noopener noreferrer"&gt;impact analysis&lt;/a&gt; shows it before the reverting migration runs, as in &lt;a href="https://schemity.com/blog/postgres-alter-column-type-used-by-view/" rel="noopener noreferrer"&gt;changing a column type a view depends on&lt;/a&gt;. For comparing two environments rather than one database over time, see &lt;a href="https://schemity.com/blog/comparing-staging-and-production-database-schemas/" rel="noopener noreferrer"&gt;comparing staging and production schemas&lt;/a&gt;. For keeping the diagram current after migrations you do run, see &lt;a href="https://schemity.com/blog/keeping-your-erd-updated-shouldnt-be-a-second-job/" rel="noopener noreferrer"&gt;keeping your ERD updated&lt;/a&gt;. And for what to let an AI agent change in the first place, see &lt;a href="https://schemity.com/blog/should-ai-agents-write-database-migrations/" rel="noopener noreferrer"&gt;should AI agents write database migrations&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>sql</category>
      <category>devops</category>
    </item>
    <item>
      <title>How to Auto-Update an updated_at Column: MySQL ON UPDATE vs a Database Trigger vs the ORM</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Thu, 22 Oct 2026 02:53:40 +0000</pubDate>
      <link>https://dev.to/tbson87/how-to-auto-update-an-updatedat-column-mysql-on-update-vs-a-database-trigger-vs-the-orm-2eik</link>
      <guid>https://dev.to/tbson87/how-to-auto-update-an-updatedat-column-mysql-on-update-vs-a-database-trigger-vs-the-orm-2eik</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://schemity.com" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt;, a desktop ERD tool - this post is from our blog and uses it for the examples.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; A &lt;code&gt;DEFAULT CURRENT_TIMESTAMP&lt;/code&gt; fills &lt;code&gt;updated_at&lt;/code&gt; only once, at insert. On MySQL, add &lt;code&gt;ON UPDATE CURRENT_TIMESTAMP&lt;/code&gt; to the column; on PostgreSQL, SQL Server and SQLite, write a trigger; or let the ORM set it, which works on every database but misses every write that does not go through the ORM. Pick the database side when scripts, admins or other services write to the table, and the ORM side when only one application does.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Whether &lt;code&gt;updated_at&lt;/code&gt; should be set by the database or by the ORM comes down to one question: does anything besides your application write to the table? The database side means MySQL's &lt;code&gt;ON UPDATE CURRENT_TIMESTAMP&lt;/code&gt; column clause or a trigger everywhere else; the application side means Rails timestamps, Django's &lt;code&gt;auto_now&lt;/code&gt;, Eloquent or Prisma's &lt;code&gt;@updatedAt&lt;/code&gt;. A column default alone never moves after the row is created.&lt;/p&gt;

&lt;p&gt;The short version: if anything other than your application writes rows (a migration backfill, a support engineer in &lt;code&gt;psql&lt;/code&gt;, a second service, a bulk &lt;code&gt;update_all&lt;/code&gt;), keep it in the database. If one application owns every write, the ORM is simpler and portable. The rest of this post is what each option does on the edge cases that decide it, and the schema changes that break each one later without an error. Every database behaviour below was run on MySQL 8.4.11, PostgreSQL 18.3, SQL Server 2022 and SQLite 3.54.0 in throwaway containers; the ORM behaviour is quoted from each framework's documentation or source.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why DEFAULT CURRENT_TIMESTAMP is not enough
&lt;/h2&gt;

&lt;p&gt;Most schemas start the same way. On MySQL:&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;orders&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;BIGINT&lt;/span&gt; &lt;span class="n"&gt;AUTO_INCREMENT&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;status&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;20&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;created_at&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both columns get the insert time. After that, &lt;code&gt;updated_at&lt;/code&gt; is an ordinary column, and an &lt;code&gt;UPDATE orders SET status = 'paid'&lt;/code&gt; leaves it exactly where it was. A default is a value for a missing column at insert, on every database. Keeping a "last changed" time current is a separate job, and each database gives you a different tool for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to auto update updated_at column: the three options
&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;MySQL &lt;code&gt;ON UPDATE&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;PostgreSQL trigger&lt;/th&gt;
&lt;th&gt;ORM (&lt;code&gt;auto_now&lt;/code&gt;, &lt;code&gt;@updatedAt&lt;/code&gt;, Rails timestamps)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Available on&lt;/td&gt;
&lt;td&gt;MySQL &lt;code&gt;TIMESTAMP&lt;/code&gt; and &lt;code&gt;DATETIME&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;Any database with triggers (SQL Server and SQLite below)&lt;/td&gt;
&lt;td&gt;Any database&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An &lt;code&gt;UPDATE&lt;/code&gt; that changes nothing&lt;/td&gt;
&lt;td&gt;Column unchanged&lt;/td&gt;
&lt;td&gt;Column moves&lt;/td&gt;
&lt;td&gt;Rails sends no &lt;code&gt;UPDATE&lt;/code&gt;; Django's &lt;code&gt;save()&lt;/code&gt; moves it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An &lt;code&gt;UPDATE&lt;/code&gt; that sets &lt;code&gt;updated_at&lt;/code&gt; itself&lt;/td&gt;
&lt;td&gt;Your value is kept&lt;/td&gt;
&lt;td&gt;Your value is overwritten&lt;/td&gt;
&lt;td&gt;Rails and Prisma keep it, Django's &lt;code&gt;auto_now&lt;/code&gt; overwrites it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Raw SQL and scripts&lt;/td&gt;
&lt;td&gt;Covered&lt;/td&gt;
&lt;td&gt;Covered&lt;/td&gt;
&lt;td&gt;Not covered&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bulk updates&lt;/td&gt;
&lt;td&gt;Covered&lt;/td&gt;
&lt;td&gt;Covered&lt;/td&gt;
&lt;td&gt;Eloquent sets it; Django &lt;code&gt;QuerySet.update()&lt;/code&gt; and Rails &lt;code&gt;update_all&lt;/code&gt; do not&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Renaming the column&lt;/td&gt;
&lt;td&gt;The clause follows the column&lt;/td&gt;
&lt;td&gt;Next &lt;code&gt;UPDATE&lt;/code&gt; fails&lt;/td&gt;
&lt;td&gt;Change the model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where it shows up&lt;/td&gt;
&lt;td&gt;In the column definition&lt;/td&gt;
&lt;td&gt;In a trigger, outside the table&lt;/td&gt;
&lt;td&gt;In application code&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rows that decide it are the bulk and raw writes, and the rename, so each option gets its own section.&lt;/p&gt;

&lt;h3&gt;
  
  
  MySQL: ON UPDATE CURRENT_TIMESTAMP
&lt;/h3&gt;

&lt;p&gt;MySQL is the only one of the four with a column clause for this:&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="n"&gt;updated_at&lt;/span&gt; &lt;span class="nb"&gt;TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;CURRENT_TIMESTAMP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It fires only when a row's values actually change. &lt;code&gt;UPDATE orders SET status = 'new'&lt;/code&gt; on a row whose status is already &lt;code&gt;new&lt;/code&gt; reports 0 rows changed and leaves &lt;code&gt;updated_at&lt;/code&gt; alone, which matches the &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/timestamp-initialization.html" rel="noopener noreferrer"&gt;MySQL manual&lt;/a&gt;: an auto-updated column "remains unchanged if all other columns are set to their current values". An &lt;code&gt;UPDATE&lt;/code&gt; that sets &lt;code&gt;updated_at&lt;/code&gt; explicitly keeps the value it was given, which is how a data fix can backdate a row on purpose. The clause works on &lt;code&gt;DATETIME&lt;/code&gt; as well as &lt;code&gt;TIMESTAMP&lt;/code&gt;, and with fractional seconds the precision has to match on both sides: &lt;code&gt;CURRENT_TIMESTAMP(6)&lt;/code&gt; for a &lt;code&gt;TIMESTAMP(6)&lt;/code&gt; column.&lt;/p&gt;

&lt;p&gt;The trap is in later migrations. &lt;code&gt;ALTER TABLE orders MODIFY updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)&lt;/code&gt; replaces the whole column definition, and since it does not repeat the clause, the clause is gone. MySQL accepts the statement without a warning. Before it, &lt;code&gt;information_schema.columns.EXTRA&lt;/code&gt; read &lt;code&gt;DEFAULT_GENERATED on update CURRENT_TIMESTAMP(6)&lt;/code&gt;; after it, &lt;code&gt;DEFAULT_GENERATED&lt;/code&gt;. The column keeps its old values and stops moving, and nothing notices until someone asks why a row edited yesterday says it was last changed in March. &lt;code&gt;RENAME COLUMN&lt;/code&gt;, by contrast, keeps the clause.&lt;/p&gt;

&lt;h3&gt;
  
  
  PostgreSQL, SQL Server and SQLite: a trigger
&lt;/h3&gt;

&lt;p&gt;PostgreSQL has no column clause, so the column is kept current by a &lt;code&gt;BEFORE UPDATE&lt;/code&gt; trigger:&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;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;set_updated_at&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="k"&gt;trigger&lt;/span&gt; &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
    &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&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;NEW&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TRIGGER&lt;/span&gt; &lt;span class="n"&gt;orders_set_updated_at&lt;/span&gt; &lt;span class="k"&gt;BEFORE&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;EACH&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;set_updated_at&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PostgreSQL also ships the same thing as an extension, &lt;code&gt;moddatetime&lt;/code&gt;, in contrib: &lt;code&gt;CREATE EXTENSION moddatetime;&lt;/code&gt; then &lt;code&gt;CREATE TRIGGER ... BEFORE UPDATE ON orders FOR EACH ROW EXECUTE FUNCTION moddatetime(updated_at);&lt;/code&gt;. It saves writing the function and accepts only &lt;code&gt;timestamp&lt;/code&gt; or &lt;code&gt;timestamptz&lt;/code&gt; columns.&lt;/p&gt;

&lt;p&gt;Three behaviours differ from MySQL, and all three were reproduced on 18.3:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It fires on every &lt;code&gt;UPDATE&lt;/code&gt;, changed or not.&lt;/strong&gt; &lt;code&gt;UPDATE orders SET status = status&lt;/code&gt; moves &lt;code&gt;updated_at&lt;/code&gt;. Adding &lt;code&gt;WHEN (OLD.* IS DISTINCT FROM NEW.*)&lt;/code&gt; to the &lt;code&gt;CREATE TRIGGER&lt;/code&gt; gives MySQL's no-op behaviour back; an explicit value is still overwritten.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It overwrites an explicit value.&lt;/strong&gt; &lt;code&gt;UPDATE orders SET updated_at = '2020-01-01'&lt;/code&gt; stores the current time instead, with the plpgsql function and with &lt;code&gt;moddatetime&lt;/code&gt;. A backfill that needs to set historic times has to disable the trigger first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;now()&lt;/code&gt; is the start of the transaction&lt;/strong&gt;, not the moment of the update. Every row a long transaction touches gets the same time. Use &lt;code&gt;clock_timestamp()&lt;/code&gt; in the function if you need the wall clock.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;SQL Server and SQLite use an &lt;code&gt;AFTER UPDATE&lt;/code&gt; trigger that runs a second &lt;code&gt;UPDATE&lt;/code&gt; on the same row. That second update does not fire the trigger again because recursive triggers are off by default on both (&lt;code&gt;is_recursive_triggers_on = 0&lt;/code&gt;, &lt;code&gt;PRAGMA recursive_triggers&lt;/code&gt; returns &lt;code&gt;0&lt;/code&gt;). On SQL Server:&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;TRIGGER&lt;/span&gt; &lt;span class="n"&gt;orders_set_updated_at&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;AFTER&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
    &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;NOCOUNT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;updated_at&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="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SYSUTCDATETIME&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;inserted&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&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;And on SQLite:&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;TRIGGER&lt;/span&gt; &lt;span class="n"&gt;orders_set_updated_at&lt;/span&gt; &lt;span class="k"&gt;AFTER&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;OLD&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;updated_at&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&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;updated_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'%Y-%m-%d %H:%M:%f'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'now'&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;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&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;Both keep an explicit value, and both still move the column on a no-op &lt;code&gt;UPDATE&lt;/code&gt;. The SQL Server guard has a catch: &lt;code&gt;UPDATE(updated_at)&lt;/code&gt; is true whenever the column is in the &lt;code&gt;SET&lt;/code&gt; list, even with its old value, so an ORM that writes every column on save makes this trigger return every time and the column never moves. Drop the guard if your ORM does that.&lt;/p&gt;

&lt;p&gt;The weakness of a trigger is that it names the column in a place the column does not know about. Rename &lt;code&gt;updated_at&lt;/code&gt; to &lt;code&gt;modified_at&lt;/code&gt; and PostgreSQL accepts the rename, then fails the next &lt;code&gt;UPDATE&lt;/code&gt; on the table with &lt;code&gt;record "new" has no field "updated_at"&lt;/code&gt; from the plpgsql function, or &lt;code&gt;"orders" has no attribute "updated_at"&lt;/code&gt; from &lt;code&gt;moddatetime&lt;/code&gt;. Dropping the column does the same. The migration succeeds; the first write after the deploy is what fails. SQL Server fails the same way after &lt;code&gt;sp_rename&lt;/code&gt;, which at least prints a caution. SQLite is the exception: it rewrites the trigger body on &lt;code&gt;RENAME COLUMN&lt;/code&gt;, though a dropped column still breaks the next &lt;code&gt;UPDATE&lt;/code&gt; with &lt;code&gt;no such column: NEW.updated_at&lt;/code&gt;. A generated column is no way out here, since it cannot call &lt;code&gt;now()&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The ORM: auto_now, @updatedAt and Rails timestamps
&lt;/h3&gt;

&lt;p&gt;Rails, Django, Laravel's Eloquent and Prisma all set &lt;code&gt;updated_at&lt;/code&gt; themselves, in application code, so the same model works on every database and the behaviour is visible in the codebase. The cost is every write that does not go through that code path, and the frameworks document where that is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Django: &lt;code&gt;auto_now&lt;/code&gt; is "only automatically updated when calling &lt;code&gt;Model.save()&lt;/code&gt;", not by &lt;code&gt;QuerySet.update()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Rails: &lt;code&gt;update_all&lt;/code&gt; "does not trigger Active Record callbacks or validations", so it does not touch &lt;code&gt;updated_at&lt;/code&gt; either.&lt;/li&gt;
&lt;li&gt;Prisma: &lt;code&gt;@updatedAt&lt;/code&gt; is "implemented at Prisma ORM level", so a raw SQL &lt;code&gt;UPDATE&lt;/code&gt; leaves it alone.&lt;/li&gt;
&lt;li&gt;Eloquent is the exception for bulk writes: its query builder's &lt;code&gt;update()&lt;/code&gt; adds &lt;code&gt;updated_at&lt;/code&gt; to the values, so &lt;code&gt;Order::where(...)-&amp;gt;update([...])&lt;/code&gt; sets it. A &lt;code&gt;DB::table('orders')-&amp;gt;update()&lt;/code&gt; does not.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Add a manual fix run in a SQL console, a nightly script, or a second service written in another language, and &lt;code&gt;updated_at&lt;/code&gt; is right for some writes and stale for others. That is acceptable when the column is for display. It is not when an incremental export, a cache, or a sync job reads &lt;code&gt;WHERE updated_at &amp;gt; :last_run&lt;/code&gt;, because a stale row is a row that sync never sends.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which should you use: MySQL ON UPDATE, a trigger or the ORM?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One application owns every write, and &lt;code&gt;updated_at&lt;/code&gt; is informational:&lt;/strong&gt; let the ORM do it. It is portable and it lives next to the model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything reads &lt;code&gt;updated_at&lt;/code&gt; to decide what changed&lt;/strong&gt; (sync, export, cache invalidation): put it in the database, because the database sees every write. On MySQL that is the column clause; elsewhere a trigger, with the &lt;code&gt;WHEN&lt;/code&gt; clause if no-op updates should not count. Database-side fixes coverage, not ordering: &lt;code&gt;now()&lt;/code&gt; is when a transaction started, so a long transaction that commits after a sync run can stamp rows earlier than that run's &lt;code&gt;:last_run&lt;/code&gt;. Query with an overlap, such as &lt;code&gt;updated_at &amp;gt; :last_run - interval '5 minutes'&lt;/code&gt;, and make the sync idempotent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Both at once&lt;/strong&gt; is harmless when the column is for display: MySQL and the SQLite trigger keep the value the ORM wrote, and a PostgreSQL trigger replaces it with its own &lt;code&gt;now()&lt;/code&gt;. For sync, pick one clock, the database's, since the application servers' clocks and time zones are not the database's.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Whichever you choose, the column's behaviour now depends on something the &lt;code&gt;CREATE TABLE&lt;/code&gt; may not show, which is what makes it easy to lose in a later migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Schemity keeps updated_at in the schema
&lt;/h2&gt;

&lt;p&gt;To design the next one without losing this, the schema, not the application, has to carry the decision. Schemity is database design software that reads your live database, shows the impact of every schema change before it runs, and keeps the diagram as a file in Git.&lt;/p&gt;

&lt;p&gt;On MySQL, the clause is part of the column in the diagram. &lt;a href="https://schemity.com/doc/reverse-engineer-database/" rel="noopener noreferrer"&gt;Import SQL&lt;/a&gt; and the live reader both read &lt;code&gt;ON UPDATE CURRENT_TIMESTAMP&lt;/code&gt;, and the &lt;a href="https://schemity.com/doc/tables-and-fields/" rel="noopener noreferrer"&gt;field dialog&lt;/a&gt; has an "On update, set to the current time" tick on &lt;code&gt;TIMESTAMP&lt;/code&gt; and &lt;code&gt;DATETIME&lt;/code&gt; columns. Because the diagram holds it, every &lt;code&gt;MODIFY&lt;/code&gt; in the &lt;a href="https://schemity.com/doc/migration-sql-diff/" rel="noopener noreferrer"&gt;migration SQL&lt;/a&gt; Schemity writes restates it, so changing the column's type or default does not quietly drop it the way a hand-written &lt;code&gt;MODIFY&lt;/code&gt; does. Converting a MySQL diagram to another database leaves the clause out and says so in the conversion notes (&lt;code&gt;ON UPDATE CURRENT_TIMESTAMP(6)&lt;/code&gt; "is left out: PostgreSQL has no column that sets itself on update"), rather than writing a column that looks the same and stops moving.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh2qxy6jknsrmchi77i2q.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh2qxy6jknsrmchi77i2q.webp" alt="Schemity's field dialog for a MySQL companies.updated_at column: TIMESTAMP with Precision 6, Default CURRENT_TIMESTAMP(6), and On update, set to the current time ticked, with the note that MySQL sets the column whenever the row's values change, even when an UPDATE does not set it, and a link to the MySQL documentation" width="799" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With the default removed and the tick left on, the planned migration still carries the clause:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6qis1riwpfsq36jayvao.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6qis1riwpfsq36jayvao.webp" alt="Schemity's change preview for orders on a connected MySQL 8.4 database after removing the default of updated_at: the Findings drawer's planned migration reads ALTER TABLE orders MODIFY COLUMN updated_at TIMESTAMP(6) NOT NULL ON UPDATE CURRENT_TIMESTAMP(6), with one planned change, column orders.updated_at altered, and no impact findings" width="800" height="474"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;On PostgreSQL, the mechanism lives outside the table, and the diagram is honest about that. Import SQL keeps &lt;code&gt;CREATE EXTENSION moddatetime&lt;/code&gt; and writes it back as &lt;code&gt;CREATE EXTENSION IF NOT EXISTS moddatetime;&lt;/code&gt; setup SQL that runs before the tables, but the trigger and its function are not part of a table in the diagram: they are counted among the skipped statements, and &lt;code&gt;updated_at&lt;/code&gt; reads as a &lt;code&gt;timestamptz&lt;/code&gt; column with its &lt;code&gt;now()&lt;/code&gt; default.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9sz0szh4rvyfpigycrko.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9sz0szh4rvyfpigycrko.webp" alt="Schemity's Import SQL drawer on an empty PostgreSQL diagram after reading a script with CREATE EXTENSION moddatetime, a posts table and its moddatetime trigger: posts is listed as New with 3 fields, the drawer says it kept 1 extension, moddatetime, which the diagram's SQL creates first, and skipped 1 statement that Schemity does not import" width="800" height="474"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When you rename or drop &lt;code&gt;updated_at&lt;/code&gt; in a diagram connected to the database, &lt;a href="https://schemity.com/doc/impact-analysis/" rel="noopener noreferrer"&gt;impact analysis&lt;/a&gt; reads the table's triggers from the catalogue and names each one, with the warning that it fails the next time it runs if it uses what changed. That is the error above, shown before the migration rather than after the deploy.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm5f0b3xdsxs6t4kjzrf0.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm5f0b3xdsxs6t4kjzrf0.webp" alt="Schemity's change preview for renaming orders.updated_at to modified_at on a connected PostgreSQL 18.3 database: the planned migration is ALTER TABLE orders RENAME COLUMN updated_at TO modified_at, and the Impact section lists one object depending on orders.updated_at, the trigger orders_set_updated_at, which fails the next time it runs if it uses what changed" width="800" height="474"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When you design the next table, the &lt;a href="https://schemity.com/doc/templates/" rel="noopener noreferrer"&gt;template&lt;/a&gt; every new entity starts from is the place to settle the columns once: &lt;code&gt;created_at&lt;/code&gt; and &lt;code&gt;updated_at&lt;/code&gt; with their types and defaults, so the question of how &lt;code&gt;updated_at&lt;/code&gt; stays current is asked for every table rather than remembered for some.&lt;/p&gt;

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

&lt;p&gt;Why a trigger and not a generated column is the right tool for &lt;code&gt;now()&lt;/code&gt; is in &lt;a href="https://schemity.com/blog/postgres-generated-column-vs-trigger/" rel="noopener noreferrer"&gt;generated column vs trigger&lt;/a&gt;. For the type of the column itself, &lt;code&gt;timestamp&lt;/code&gt; or &lt;code&gt;timestamptz&lt;/code&gt;, see &lt;a href="https://schemity.com/blog/postgres-timestamp-vs-timestamptz/" rel="noopener noreferrer"&gt;timestamp vs timestamptz&lt;/a&gt;. SQLite drops a table's triggers during a table rebuild, which is how an &lt;code&gt;updated_at&lt;/code&gt; trigger disappears in &lt;a href="https://schemity.com/blog/sqlite-change-column-type-table-rebuild/" rel="noopener noreferrer"&gt;changing a SQLite column type&lt;/a&gt;. And the &lt;a href="https://schemity.com/blog/mysql-round-trip-invoice-ninja-sylius/" rel="noopener noreferrer"&gt;MySQL round-trip benchmark&lt;/a&gt; is where Schemity's own loss of &lt;code&gt;ON UPDATE&lt;/code&gt; was found and fixed.&lt;/p&gt;

</description>
      <category>mysql</category>
      <category>postgres</category>
      <category>database</category>
      <category>sql</category>
    </item>
    <item>
      <title>SQL Server INT to BIGINT Primary Key: Four Ways to Change It and What Each One Costs</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Wed, 21 Oct 2026 07:02:37 +0000</pubDate>
      <link>https://dev.to/tbson87/sql-server-int-to-bigint-primary-key-four-ways-to-change-it-and-what-each-one-costs-df6</link>
      <guid>https://dev.to/tbson87/sql-server-int-to-bigint-primary-key-four-ways-to-change-it-and-what-each-one-costs-df6</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://schemity.com" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt;, a desktop ERD tool - this post is from our blog and uses it for the examples.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; SQL Server refuses &lt;code&gt;ALTER COLUMN&lt;/code&gt; on an &lt;code&gt;INT&lt;/code&gt; primary key while the key, any foreign key or any index uses the column, so the in-place change is drop, alter, recreate. On two 1,000,000-row tables that took 20.5 seconds and about 1 GB of transaction log. Row compression stops the &lt;code&gt;ALTER COLUMN&lt;/code&gt; from rewriting rows, new tables copied across avoid the long lock, and a negative reseed buys time when no window is available yet.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To change an &lt;code&gt;INT&lt;/code&gt; primary key to &lt;code&gt;BIGINT&lt;/code&gt; in SQL Server, you have to drop everything that uses the column first: every foreign key that references it, the primary key itself, and the indexes on the referencing columns. A plain &lt;code&gt;ALTER COLUMN&lt;/code&gt; fails with Msg 5074 while any of them exists, and once they are gone the change rewrites every row of every table involved under a lock that blocks all reads and writes.&lt;/p&gt;

&lt;p&gt;The question usually arrives as an outage. An &lt;code&gt;IDENTITY(1,1)&lt;/code&gt; key reaches 2,147,483,647 and the next insert fails with Msg 8115, Level 16: "Arithmetic overflow error converting IDENTITY to data type int." Below are the four ways out, run on SQL Server 2022 (CU26, Developer edition) in a throwaway container. That container ran under x86 emulation on a laptop, so read the timings as ratios between methods, not as what your server will do.&lt;/p&gt;

&lt;h2&gt;
  
  
  How close are your INT keys to the limit?
&lt;/h2&gt;

&lt;p&gt;Before choosing a method, find out how much time you have. &lt;code&gt;sys.identity_columns&lt;/code&gt; keeps the last value each identity handed out:&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;OBJECT_SCHEMA_NAME&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;schema_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;OBJECT_NAME&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;ic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;CAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last_value&lt;/span&gt; &lt;span class="k"&gt;AS&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;AS&lt;/span&gt; &lt;span class="n"&gt;last_value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;CAST&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;CAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last_value&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2147483647&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;decimal&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;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;pct_used&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;identity_columns&lt;/span&gt; &lt;span class="n"&gt;ic&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;TYPE_NAME&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;system_type_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'int'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;OBJECTPROPERTY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'IsUserTable'&lt;/span&gt;&lt;span class="p"&gt;)&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;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;pct_used&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;A table at 80% with a steady insert rate gives you a date. A negative &lt;code&gt;last_value&lt;/code&gt; and percentage mean someone already reseeded it, which is the last method below. The query covers the keys; the foreign key columns that point at them are just as &lt;code&gt;INT&lt;/code&gt;, and every one of them has to change in the same migration, because SQL Server will not keep a foreign key across two types. Creating an &lt;code&gt;INT&lt;/code&gt; foreign key against a &lt;code&gt;BIGINT&lt;/code&gt; key failed with Msg 1778, "is not the same data type as referencing column", and even a &lt;code&gt;VARCHAR(20)&lt;/code&gt; column against a &lt;code&gt;VARCHAR(40)&lt;/code&gt; key failed with Msg 1753, "must be defined with the same length and scale".&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does ALTER COLUMN fail on an INT primary key?
&lt;/h2&gt;

&lt;p&gt;Because SQL Server checks every object that uses the column before it changes the type. With &lt;code&gt;orders.user_id&lt;/code&gt; referencing &lt;code&gt;users.id&lt;/code&gt; and an index on &lt;code&gt;orders.user_id&lt;/code&gt;, the obvious statement:&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;users&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;id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Msg 5074, Level 16, State 1
The object 'PK_users' is dependent on column 'id'.
Msg 5074, Level 16, State 1
The object 'FK_orders_users' is dependent on column 'id'.
Msg 4922, Level 16, State 9
ALTER TABLE ALTER COLUMN id failed because one or more objects access this column.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Changing the referencing column first fails the same way, naming &lt;code&gt;IX_orders_user_id&lt;/code&gt; and &lt;code&gt;FK_orders_users&lt;/code&gt;. The &lt;a href="https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-table-transact-sql" rel="noopener noreferrer"&gt;ALTER TABLE documentation&lt;/a&gt; lists the objects that block a type change: a &lt;code&gt;PRIMARY KEY&lt;/code&gt; or &lt;code&gt;FOREIGN KEY&lt;/code&gt; constraint, a &lt;code&gt;CHECK&lt;/code&gt; or &lt;code&gt;UNIQUE&lt;/code&gt; constraint, a default, an index, statistics made with &lt;code&gt;CREATE STATISTICS&lt;/code&gt;, and a computed column. In the same container, a &lt;code&gt;CHECK (n &amp;gt; 0)&lt;/code&gt; on the column, an index that only &lt;code&gt;INCLUDE&lt;/code&gt;s it, and a view created &lt;code&gt;WITH SCHEMABINDING&lt;/code&gt; that selects it all blocked &lt;code&gt;INT&lt;/code&gt; to &lt;code&gt;BIGINT&lt;/code&gt; as well. On a real database, look for schema-bound views and functions before you plan the window: each one has to be dropped and created again around the change.&lt;/p&gt;

&lt;p&gt;Two things do not get you past it. &lt;code&gt;WITH (ONLINE = ON)&lt;/code&gt;, available since SQL Server 2016 and on Enterprise edition only, returned the same Msg 5074 on a key column. And the one exception the documentation makes is narrower than it looks: an index, a &lt;code&gt;CHECK&lt;/code&gt; or a &lt;code&gt;UNIQUE&lt;/code&gt; constraint lets a &lt;code&gt;VARCHAR&lt;/code&gt;, &lt;code&gt;NVARCHAR&lt;/code&gt; or &lt;code&gt;VARBINARY&lt;/code&gt; column grow to a longer length of the same type. In the container the same growth also passed under a primary key, but a foreign key still blocked it. &lt;code&gt;INT&lt;/code&gt; to &lt;code&gt;BIGINT&lt;/code&gt; gets no exception.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you change an INT primary key to BIGINT in SQL Server?
&lt;/h2&gt;

&lt;p&gt;In place, it is eight statements for one key and one referencing table, inside one transaction so that a failure halfway does not leave &lt;code&gt;users&lt;/code&gt; without its primary key. Foreign keys go first, because the primary key cannot be dropped while one references 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;XACT_ABORT&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt; &lt;span class="n"&gt;TRANSACTION&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;FK_orders_users&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;users&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;PK_users&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;INDEX&lt;/span&gt; &lt;span class="n"&gt;IX_orders_user_id&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&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;users&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;id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="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;user_id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;IX_orders_user_id&lt;/span&gt; &lt;span class="k"&gt;ON&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;user_id&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;users&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;PK_users&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;id&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;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;FK_orders_users&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_id&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;id&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 &lt;code&gt;IDENTITY&lt;/code&gt; property survives the type change. After the script, &lt;code&gt;sys.columns&lt;/code&gt; still showed &lt;code&gt;is_identity = 1&lt;/code&gt; on &lt;code&gt;users.id&lt;/code&gt;, and after &lt;code&gt;DBCC CHECKIDENT ('users', RESEED, 2147483647)&lt;/code&gt; the next insert got 2,147,483,648.&lt;/p&gt;

&lt;p&gt;The cost is the rewrite. With 1,000,000 rows in &lt;code&gt;users&lt;/code&gt; and 1,000,000 in &lt;code&gt;orders&lt;/code&gt;, in a database whose full backup was 82 MB, the whole script took &lt;strong&gt;20.5 seconds&lt;/strong&gt; and grew the used transaction log from 6 MB to &lt;strong&gt;1,015 MB&lt;/strong&gt;. A single &lt;code&gt;ALTER COLUMN&lt;/code&gt; from &lt;code&gt;INT&lt;/code&gt; to &lt;code&gt;BIGINT&lt;/code&gt; on a 1,000,000-row table with no key on the column took 1.6 seconds and 365 MB of log on its own. The documentation says &lt;code&gt;ALTER TABLE&lt;/code&gt; holds a schema modification (&lt;code&gt;Sch-M&lt;/code&gt;) lock "to ensure that no other connections reference even the metadata for the table during the change", so nothing reads or writes either table until it commits. Multiply by every table that references &lt;code&gt;users&lt;/code&gt;, and by the real row counts, and size the log file before you start. &lt;code&gt;PK_users&lt;/code&gt; is clustered, the default for a primary key, so dropping it leaves &lt;code&gt;users&lt;/code&gt; as a heap and adding it back sorts the whole table into a clustered index again. If the original key was &lt;code&gt;NONCLUSTERED&lt;/code&gt;, say so in the &lt;code&gt;ADD CONSTRAINT&lt;/code&gt;, or it comes back clustered.&lt;/p&gt;

&lt;h3&gt;
  
  
  Restate NOT NULL and COLLATE on every ALTER COLUMN
&lt;/h3&gt;

&lt;p&gt;The same documentation hides a second trap: "if not specified, the column is nullable", and "if you don't specify a collation, the column is assigned the default collation of the database". On a &lt;code&gt;NOT NULL&lt;/code&gt; column created as &lt;code&gt;NVARCHAR(255) COLLATE Latin1_General_CS_AS&lt;/code&gt;, this statement succeeded:&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;users&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;email&lt;/span&gt; &lt;span class="n"&gt;NVARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;320&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and left &lt;code&gt;email&lt;/code&gt; nullable and on the database default collation, in that container the case-insensitive &lt;code&gt;SQL_Latin1_General_CP1_CI_AS&lt;/code&gt;. No error, no warning. For a key widening the rule is simple: every &lt;code&gt;ALTER COLUMN&lt;/code&gt; says &lt;code&gt;NOT NULL&lt;/code&gt;, and every string column says its &lt;code&gt;COLLATE&lt;/code&gt; again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which way should you widen it?
&lt;/h2&gt;

&lt;p&gt;The in-place script is not the only option. Each of the four trades something different:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;What changes&lt;/th&gt;
&lt;th&gt;Measured here&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;In place: drop keys, &lt;code&gt;ALTER COLUMN&lt;/code&gt;, recreate&lt;/td&gt;
&lt;td&gt;The key and every referencing column, for good&lt;/td&gt;
&lt;td&gt;20.5 s and ~1 GB of log for 2 x 1,000,000 rows, both tables locked throughout&lt;/td&gt;
&lt;td&gt;The tables fit in a maintenance window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Row compression first, then in place&lt;/td&gt;
&lt;td&gt;The same, but the &lt;code&gt;ALTER COLUMN&lt;/code&gt; stops rewriting rows&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ALTER COLUMN&lt;/code&gt; took 0 ms and 0 MB of log; re-adding the clustered primary key still rebuilt the table in 1.0 s&lt;/td&gt;
&lt;td&gt;The table is already row- or page-compressed, or a compression rebuild fits now&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New &lt;code&gt;BIGINT&lt;/code&gt; tables, copy across, rename&lt;/td&gt;
&lt;td&gt;New tables swapped in with &lt;code&gt;sp_rename&lt;/code&gt;, the key's and each large referencing table's&lt;/td&gt;
&lt;td&gt;Not measured: the copies run in batches while the old tables keep serving&lt;/td&gt;
&lt;td&gt;The tables are too big to lock for the length of a rewrite&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reseed to -2,147,483,648&lt;/td&gt;
&lt;td&gt;Nothing in the schema&lt;/td&gt;
&lt;td&gt;Instant&lt;/td&gt;
&lt;td&gt;You need weeks, not hours, and every consumer accepts negative ids&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Row compression&lt;/strong&gt; stores integers in "only the bytes that are needed", according to Microsoft's row compression documentation, so &lt;code&gt;INT&lt;/code&gt; and &lt;code&gt;BIGINT&lt;/code&gt; share one on-disk format and the type change becomes a catalog edit. On a row-compressed 1,000,000-row table, the same &lt;code&gt;ALTER COLUMN&lt;/code&gt; that took 1.6 seconds uncompressed took 0 ms and wrote no log. It does not lift Msg 5074: on a compressed table with the key on the column, the &lt;code&gt;ALTER COLUMN&lt;/code&gt; was still refused until the primary key was dropped, and adding it back took 1.0 second. Compressing an existing table is itself a rebuild (0.36 seconds for 1,000,000 narrow rows here). Data compression is in every edition of SQL Server 2022, Express included; doing that rebuild &lt;code&gt;ONLINE&lt;/code&gt; is what needs Enterprise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A new table&lt;/strong&gt; is the method for a table you cannot lock: create &lt;code&gt;users_new&lt;/code&gt; with &lt;code&gt;id BIGINT IDENTITY&lt;/code&gt;, copy rows across in batches with &lt;code&gt;SET IDENTITY_INSERT users_new ON&lt;/code&gt;, keep the two in step with a trigger or a final catch-up pass, then swap the names with &lt;code&gt;sp_rename&lt;/code&gt; in a short transaction. The foreign keys cannot simply be recreated against the new table: every referencing column is still &lt;code&gt;INT&lt;/code&gt;, and Msg 1778 refuses a key across two types, so each large child table needs the same copy, or row compression, of its own before the keys come back. It is the most work and the only one where the long part runs while the application keeps writing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reseeding&lt;/strong&gt; changes no column. Once the identity has overflowed, &lt;code&gt;DBCC CHECKIDENT&lt;/code&gt; with &lt;code&gt;RESEED, -2147483648&lt;/code&gt; makes the next insert -2,147,483,647, because, as Microsoft's documentation for the command puts it, on a table with rows the next one "uses new_reseed_value + the current increment value". That buys another 2.1 billion inserts in the negative half. It is a stopgap: every API, client and report that assumed positive ids sees negative ones, and the ids stop sorting by age.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Schemity plans the change and shows what it costs
&lt;/h2&gt;

&lt;p&gt;Schemity is database design software that reads your live database, shows the impact of every schema change before it runs, and keeps the diagram as a file in Git. After you &lt;a href="https://schemity.com/doc/connect-sql-server/" rel="noopener noreferrer"&gt;connect to SQL Server&lt;/a&gt;, every key is drawn with its real type, so an &lt;code&gt;INT&lt;/code&gt; key and the &lt;code&gt;INT&lt;/code&gt; foreign key columns that point at it are visible together on the canvas, with the &lt;a href="https://schemity.com/doc/relationships/" rel="noopener noreferrer"&gt;relationships&lt;/a&gt; between them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp7qme36si3nb59eormgi.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fp7qme36si3nb59eormgi.webp" alt="Schemity's canvas reading the orders and users tables from SQL Server 16.0: users.id is an INT primary key marked auto for its identity, beside email NVARCHAR(255) and created_at DATETIME2 with the default SYSUTCDATETIME(); orders.id is an INT identity key, orders.user_id is an INT foreign key whose relation line runs to users.id, and orders.total is DECIMAL(12,2), with idx: 1 for the index on user_id" width="800" height="486"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Change &lt;code&gt;users.id&lt;/code&gt; to &lt;code&gt;BIGINT&lt;/code&gt; in the &lt;a href="https://schemity.com/doc/tables-and-fields/" rel="noopener noreferrer"&gt;field editor&lt;/a&gt; and &lt;a href="https://schemity.com/doc/schema-lint/" rel="noopener noreferrer"&gt;lint&lt;/a&gt; reports each foreign key column still on &lt;code&gt;INT&lt;/code&gt; as "Foreign key type does not match the column it references", filed under the findings that fail at runtime with the detail that SQL Server refuses a foreign key whose column and key differ in type. The referencing columns are found by the diagram rather than by Msg 1778 at deploy time.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk72l1rnasp6uyufk1ad7.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk72l1rnasp6uyufk1ad7.webp" alt="Schemity's Lint drawer after users.id was changed to BIGINT while orders.user_id stayed INT: under Fails at runtime, the finding on orders.user_id reads Foreign key type does not match the column it references, with the detail that the two columns differ in type and SQL Server refuses a foreign key whose column and key differ in type; a red bar in the canvas margin marks the orders.user_id row" width="800" height="486"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Change them too, and the planned schema migration is the in-place script above: it drops the foreign keys, the primary key and the indexes that use the columns, restates &lt;code&gt;NOT NULL&lt;/code&gt; on every column that has it, keeps a non-default &lt;code&gt;COLLATE&lt;/code&gt; on string columns, and creates everything again in dependency order. A &lt;code&gt;VARCHAR&lt;/code&gt;, &lt;code&gt;NVARCHAR&lt;/code&gt; or &lt;code&gt;VARBINARY&lt;/code&gt; that only grows keeps its index, because SQL Server allows that change in place. A column renamed and retyped in the same save becomes one &lt;code&gt;ALTER COLUMN&lt;/code&gt; plus &lt;code&gt;sp_rename&lt;/code&gt;, and an identity added, removed or given a new step is refused with an explanation, because SQL Server can only change that by copying the table.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk3ncnzzsb6mg7qvr86ma.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk3ncnzzsb6mg7qvr86ma.webp" alt="Schemity's Migration confirm dialog for the users.id and orders.user_id change to BIGINT on SQL Server 16.0: the findings list the primary key added back on users under Can fail on existing data, both table rewrites at ~1M rows, five statements that hold back reads or writes, and the foreign key dropped and recreated on orders; the eight planned statements drop FK_orders_users, PK_users and IX_orders_user_id, alter orders.user_id and users.id to BIGINT NOT NULL, then create IX_orders_user_id, PK_users and FK_orders_users again, all run in one transaction that rolls back on any error" width="800" height="774"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Before anything runs, &lt;a href="https://schemity.com/doc/impact-analysis/" rel="noopener noreferrer"&gt;impact analysis&lt;/a&gt; lets you see the impact of every change in the plan: "Rewrites users to change id" and "Rewrites orders to change user_id", each with the table's row estimate and size on disk, the lock each type change takes ("holds back reads and writes to users while it reads ~1M rows"), the index rebuild and the key checks that hold back writes, "Drops and recreates a foreign key on orders", and the primary key it adds back, with the duplicate check any new key gets. Those row estimates are the numbers to set against the 20.5 seconds and 1 GB above before you book the window.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxayl60n1oh3mcesq3lgv.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxayl60n1oh3mcesq3lgv.webp" alt="Schemity's Impact drawer after users.id and orders.user_id were both changed to BIGINT, with orders.id left INT: Adds a primary key on users, fails if duplicates exist among ~1M rows, with a Count exactly button; Rewrites orders to change user_id at ~1M rows and 39 MB on disk, and Rewrites users to change id at ~1M rows and 48 MB; five findings under Holds back other statements, the two type changes holding back reads and writes, building the index on orders and checking the two keys holding back writes; and under Spreads through the diagram, Changing users.id reaches orders via FK_orders_users, and Drops and recreates a foreign key on orders" width="800" height="774"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  INT to BIGINT on SQL Server, in one list
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Find the tables at risk with &lt;code&gt;sys.identity_columns&lt;/code&gt; before an insert fails with Msg 8115.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ALTER COLUMN&lt;/code&gt; on a key column fails with Msg 5074 and 4922 until every primary key, foreign key, unique, check, index and schema-bound view on it is dropped. &lt;code&gt;ONLINE = ON&lt;/code&gt; does not change that.&lt;/li&gt;
&lt;li&gt;In place means drop, alter, recreate, with every table locked and its rows rewritten. Budget the transaction log: 1 GB for two 1,000,000-row tables here.&lt;/li&gt;
&lt;li&gt;Always restate &lt;code&gt;NOT NULL&lt;/code&gt; and the column's &lt;code&gt;COLLATE&lt;/code&gt; in &lt;code&gt;ALTER COLUMN&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Row compression stops the &lt;code&gt;ALTER COLUMN&lt;/code&gt; from rewriting rows; new tables avoid the long lock; a negative reseed only buys time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The PostgreSQL version of this migration has its own catch, a &lt;code&gt;serial&lt;/code&gt; sequence that stays &lt;code&gt;integer&lt;/code&gt; after the column is widened, covered in &lt;a href="https://schemity.com/blog/postgres-serial-vs-identity/" rel="noopener noreferrer"&gt;serial vs identity in Postgres&lt;/a&gt;. MySQL's version fails on the foreign keys too, with error 3780, as shown in &lt;a href="https://schemity.com/blog/mysql-unsigned-vs-signed-primary-key/" rel="noopener noreferrer"&gt;MySQL UNSIGNED primary keys&lt;/a&gt;, and how Schemity reads two real SQL Server schemas end to end is in &lt;a href="https://schemity.com/blog/sqlserver-round-trip-bitwarden-smartstore/" rel="noopener noreferrer"&gt;the SQL Server round trip&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>sqlserver</category>
      <category>database</category>
      <category>sql</category>
      <category>dotnet</category>
    </item>
    <item>
      <title>Round-Tripping TPC-DS and a Snowflake Feature Schema Through a Database Diagram: What Snowflake Lost and What We Fixed</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Tue, 20 Oct 2026 14:53:37 +0000</pubDate>
      <link>https://dev.to/tbson87/round-tripping-tpc-ds-and-a-snowflake-feature-schema-through-a-database-diagram-what-snowflake-570a</link>
      <guid>https://dev.to/tbson87/round-tripping-tpc-ds-and-a-snowflake-feature-schema-through-a-database-diagram-what-snowflake-570a</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://schemity.com" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt;, a desktop ERD tool - this post is from our blog and uses it for the examples.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; We put two Snowflake schemas through Schemity, TPC-DS as Snowflake ships it and a hand-written schema using Snowflake's own features, and rebuilt each in a fresh schema from the SQL Schemity wrote. Before this round of fixes the second script stopped partway, because a table with an expression clustering key could not be read; every clustering key and &lt;code&gt;TRANSIENT&lt;/code&gt; was dropped, an identity lost its start and step, and TPC-DS's 17 single-column surrogate keys came back as &lt;code&gt;AUTOINCREMENT&lt;/code&gt;. After the fixes TPC-DS comes back with no catalog difference, the second schema differs only in two comments, and all 14 behaviour probes match the originals.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A database diagram that reads a Snowflake schema and writes it back should give you the same schema, and before this round of fixes Schemity did not. We took two schemas, TPC-DS as Snowflake ships it in its sample database (24 tables) and a hand-written schema built from the Snowflake features real projects use (6 tables), imported each from Snowflake's own &lt;code&gt;GET_DDL&lt;/code&gt;, and rebuilt it in a fresh schema from the SQL Schemity wrote. Snowflake stopped the second script partway through, and the first came back with every clustering key gone and 17 keys numbering themselves. After the fixes, the SQL Schemity writes runs on Snowflake as it is, with no edit, TPC-DS comes back with no catalog difference, the feature schema differs only in two comments, and both behave like their originals in 14 behaviour probes.&lt;/p&gt;

&lt;p&gt;Schemity is database design software that reads your live database, shows the impact of every schema change before it runs, and keeps the diagram as a file in Git. This is the fifth and last post in a series that tests the round trip on each database it supports, after &lt;a href="https://schemity.com/blog/postgres-round-trip-mastodon-saleor/" rel="noopener noreferrer"&gt;Mastodon and Saleor on PostgreSQL&lt;/a&gt;, &lt;a href="https://schemity.com/blog/mysql-round-trip-invoice-ninja-sylius/" rel="noopener noreferrer"&gt;Invoice Ninja and Sylius on MySQL&lt;/a&gt;, &lt;a href="https://schemity.com/blog/sqlserver-round-trip-bitwarden-smartstore/" rel="noopener noreferrer"&gt;Bitwarden and Smartstore on SQL Server&lt;/a&gt; and &lt;a href="https://schemity.com/blog/sqlite-round-trip-campfire-jellyfin/" rel="noopener noreferrer"&gt;Campfire and Jellyfin on SQLite&lt;/a&gt;. Reading a schema you did not write is how you understand your schema before changing it, and Snowflake tested the part of a table the other four never needed: how the warehouse stores it, not only what its columns are.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which two schemas, and which path?
&lt;/h2&gt;

&lt;p&gt;There is no open-source application whose schema is built for Snowflake the way Mastodon's is built for PostgreSQL, so this post uses two kinds instead. TPC-DS is the TPC's decision-support benchmark, the schema analytics engines are measured on, and Snowflake ships it with its keys declared. The second is ours: a billing and events model written to use what Snowflake projects do and the other four databases never needed.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;TPC-DS&lt;/th&gt;
&lt;th&gt;Feature schema&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What it is&lt;/td&gt;
&lt;td&gt;The TPC's decision-support schema, from &lt;code&gt;SNOWFLAKE_SAMPLE_DATA.TPCDS_SF10TCL&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Customers, products, orders, order items, raw events and a daily rollup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tables / columns&lt;/td&gt;
&lt;td&gt;24 / 425&lt;/td&gt;
&lt;td&gt;6 / 37&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keys&lt;/td&gt;
&lt;td&gt;24 primary keys, 105 foreign keys&lt;/td&gt;
&lt;td&gt;5 primary keys and 2 unique keys, 2 of them composite, and 3 foreign keys&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clustering keys&lt;/td&gt;
&lt;td&gt;24, on a column&lt;/td&gt;
&lt;td&gt;2, one on an expression: &lt;code&gt;CLUSTER BY (TO_DATE(ORDERED_AT))&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Also has&lt;/td&gt;
&lt;td&gt;nothing else&lt;/td&gt;
&lt;td&gt;an identity with &lt;code&gt;START 100 INCREMENT 5&lt;/code&gt;, a sequence default, &lt;code&gt;COLLATE 'en-ci'&lt;/code&gt;, a &lt;code&gt;TRANSIENT&lt;/code&gt; table, &lt;code&gt;OBJECT&lt;/code&gt; / &lt;code&gt;VARIANT&lt;/code&gt; / &lt;code&gt;ARRAY&lt;/code&gt;, &lt;code&gt;TIMESTAMP_LTZ(9)&lt;/code&gt; / &lt;code&gt;TIMESTAMP_TZ(3)&lt;/code&gt; / &lt;code&gt;TIMESTAMP_NTZ(6)&lt;/code&gt;, &lt;code&gt;TIME(0)&lt;/code&gt;, &lt;code&gt;BINARY(16)&lt;/code&gt;, two comments&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Snowflake refuses &lt;code&gt;GET_DDL&lt;/code&gt; on a shared database such as its sample data, so TPC-DS was copied into an empty schema with &lt;code&gt;CREATE TABLE ... LIKE&lt;/code&gt;, which keeps the columns and the clustering keys, and the sample's keys were added from its own &lt;code&gt;SHOW PRIMARY KEYS&lt;/code&gt; and &lt;code&gt;SHOW IMPORTED KEYS&lt;/code&gt;. Everything ran on a Snowflake trial account, version 10.37, on an X-Small warehouse.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdgfrnqy8bnhdcidvgoxc.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdgfrnqy8bnhdcidvgoxc.webp" alt="Part of the TPC-DS diagram in Schemity, arranged into groups with the minimap in the corner: the Catalog Channel group holds CATALOG_RETURNS, CALL_CENTER, CATALOG_SALES and CATALOG_PAGE, with CUSTOMER_ADDRESS, CUSTOMER_DEMOGRAPHICS and INCOME_BAND in the group beside it, and the status bar reads 24 entities and 105 relations" width="800" height="527"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Unlike the other four databases, Schemity has no live connection to Snowflake: a Snowflake diagram is designed from a script and its SQL is run by you. So unlike the other four posts there is one path:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Import SQL:&lt;/strong&gt; &lt;code&gt;GET_DDL&lt;/code&gt; of each original schema, read by Schemity's SQL parser and mapped into a diagram the way the app maps it, with no connection.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2vhvs8o1qxbtiakyrnj5.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2vhvs8o1qxbtiakyrnj5.webp" alt="Schemity's Import SQL summary for the feature schema's GET_DDL script on an empty Snowflake diagram: CUSTOMERS, DAILY_REVENUE, ORDERS, ORDER_ITEMS, PRODUCTS and RAW_EVENTS each marked New with 8, 4, 7, 6, 6 and 6 fields, the note that the CREATE SEQUENCE for ORDER_SEQ was kept because a column default draws on it, one skipped statement that is not a table, and Infer virtual relations from column names ticked" width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The diagram's SQL, as View SQL writes it, then ran in a fresh schema on the same account. The runs used Schemity's own code from a development build, called from scripts rather than through the app's window, and include the step the app takes when it turns a parsed table into a diagram. The earlier posts' scripts skipped it, and that step reordered every table's columns until the fix described in the SQLite post. The fixes described below are in that build and ship in the next release; until then, the released app has the losses in the "before" column.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you prove a rebuilt Snowflake schema is the same?
&lt;/h2&gt;

&lt;p&gt;The same two checks as the earlier posts, with one change Snowflake forces on the second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The catalog diff.&lt;/strong&gt; A script lists one line per fact for the original schema and for the copy, from &lt;code&gt;INFORMATION_SCHEMA&lt;/code&gt; and Snowflake's &lt;code&gt;SHOW&lt;/code&gt; commands: every table with whether it is transient, its clustering key and its comment; every column with its position, type, length or precision, nullability, default, identity start, step and order, collation and comment; every primary, unique and foreign key by its columns; every sequence with its start and step. The two lists are diffed line by line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The behaviour probes.&lt;/strong&gt; A probe is a few statements in one transaction that is rolled back, ending in a &lt;code&gt;SELECT&lt;/code&gt; of what was stored or in Snowflake's error. On standard tables Snowflake &lt;a href="https://docs.snowflake.com/en/sql-reference/constraints-overview" rel="noopener noreferrer"&gt;records primary, unique and foreign keys but does not enforce them&lt;/a&gt;; it enforces &lt;code&gt;NOT NULL&lt;/code&gt; and &lt;code&gt;CHECK&lt;/code&gt;. So a probe here cannot ask which duplicate a copy refuses, as the earlier posts did. It asks what a value becomes: the ids an identity hands out, whether a case-insensitive collation finds a match, what a &lt;code&gt;NUMBER(12,2)&lt;/code&gt; stores for &lt;code&gt;10.005&lt;/code&gt;, what a &lt;code&gt;TIMESTAMP_TZ(3)&lt;/code&gt; keeps of &lt;code&gt;10:00:00.123456 +02:00&lt;/code&gt;, and whether a row that leaves out a key is refused.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;What it compares&lt;/th&gt;
&lt;th&gt;What it catches&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Catalog diff&lt;/td&gt;
&lt;td&gt;Tables, columns, keys and sequences as Snowflake reports them&lt;/td&gt;
&lt;td&gt;Anything missing, added or changed in the schema&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Probes&lt;/td&gt;
&lt;td&gt;What each schema stores, numbers and refuses&lt;/td&gt;
&lt;td&gt;An identity, default, collation or type that behaves differently&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;There are 14 probes: 12 for the feature schema and 2 for TPC-DS. An identity or a sequence hands out its numbers even inside a rolled-back transaction, so each copy of the feature schema was rebuilt fresh before its probes ran; TPC-DS has neither.&lt;/p&gt;

&lt;h2&gt;
  
  
  What failed before the fixes?
&lt;/h2&gt;

&lt;p&gt;Everything below is from Schemity's code just before these fixes (commit &lt;code&gt;3360cb61&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Snowflake stopped the feature schema's script partway.&lt;/strong&gt; It created the first three tables and stopped at &lt;code&gt;ORDER_ITEMS&lt;/code&gt; with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;002003 (42S02): SQL compilation error: Table 'SPIKE_DB.FEATURES_BEFORE.ORDERS' does not exist or not authorized.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Import SQL had not read &lt;code&gt;ORDERS&lt;/code&gt; at all, so &lt;code&gt;ORDER_ITEMS&lt;/code&gt;'s foreign key named a table the script never created. With that one key removed by hand the script ran, and the copy could be compared.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;TPC-DS&lt;/th&gt;
&lt;th&gt;Feature schema&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Script ran&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;stopped at the fourth table, error 002003; ran once patched&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tables lost&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1, &lt;code&gt;ORDERS&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clustering keys lost&lt;/td&gt;
&lt;td&gt;24 of 24&lt;/td&gt;
&lt;td&gt;2 of 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;TRANSIENT&lt;/code&gt; dropped&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;1 of 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keys given an identity they do not have&lt;/td&gt;
&lt;td&gt;17&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Identity start and step lost&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Collation dropped&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Catalog lines that differ&lt;/td&gt;
&lt;td&gt;82&lt;/td&gt;
&lt;td&gt;22&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Probes that match the original&lt;/td&gt;
&lt;td&gt;0 of 2&lt;/td&gt;
&lt;td&gt;5 of 12&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The probes that failed are the ones that matter to the data:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Probe&lt;/th&gt;
&lt;th&gt;Original&lt;/th&gt;
&lt;th&gt;Before the fixes&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Feature schema: the first two customer ids&lt;/td&gt;
&lt;td&gt;&lt;code&gt;100, 105&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;1, 2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;100, 105&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Feature schema: &lt;code&gt;'A@X.COM'&lt;/code&gt; finds &lt;code&gt;'a@x.com'&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1 row&lt;/td&gt;
&lt;td&gt;0 rows&lt;/td&gt;
&lt;td&gt;1 row&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Feature schema: the 5 probes on &lt;code&gt;ORDERS&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;stored or refused as declared&lt;/td&gt;
&lt;td&gt;refused, no table&lt;/td&gt;
&lt;td&gt;stored or refused as declared&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TPC-DS: a row in &lt;code&gt;CALL_CENTER&lt;/code&gt; or &lt;code&gt;DATE_DIM&lt;/code&gt; without its key&lt;/td&gt;
&lt;td&gt;refused, error 100072&lt;/td&gt;
&lt;td&gt;accepted, key 1&lt;/td&gt;
&lt;td&gt;refused&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In the tables that were read, &lt;code&gt;NOT NULL&lt;/code&gt;, every type, length, precision and scale, defaults, &lt;code&gt;VARIANT&lt;/code&gt;, &lt;code&gt;OBJECT&lt;/code&gt; and &lt;code&gt;ARRAY&lt;/code&gt;, &lt;code&gt;TIMESTAMP_LTZ(9)&lt;/code&gt; and &lt;code&gt;TIMESTAMP_NTZ(6)&lt;/code&gt; with their precision, &lt;code&gt;TIME(0)&lt;/code&gt;, &lt;code&gt;BINARY(16)&lt;/code&gt;, every primary, unique and foreign key and the column order all survived; &lt;code&gt;ORDERS&lt;/code&gt;, with its &lt;code&gt;TIMESTAMP_TZ(3)&lt;/code&gt;, its keys and its sequence default, was lost whole. Each failure had its own cause.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why did a table with a clustering key disappear?
&lt;/h3&gt;

&lt;p&gt;Because the clause was in a place the parser did not expect it. &lt;code&gt;GET_DDL&lt;/code&gt; writes a table's clustering key between its name and its column list, &lt;code&gt;create or replace TABLE ORDERS cluster by (TO_DATE(ORDERED_AT))(&lt;/code&gt;, and &lt;a href="https://docs.snowflake.com/en/sql-reference/sql/create-table" rel="noopener noreferrer"&gt;Snowflake allows an expression there&lt;/a&gt;, not only column names. The SQL parser Schemity uses reads only column names in that clause, so it refused the statement, and Import SQL listed &lt;code&gt;ORDERS&lt;/code&gt; as a table it could not read. TPC-DS's clustering keys are plain columns, so its tables were read, but the clause was then dropped with nothing to hold it. &lt;a href="https://schemity.com/doc/snowflake/" rel="noopener noreferrer"&gt;Import SQL&lt;/a&gt; now takes &lt;code&gt;CLUSTER BY (...)&lt;/code&gt; out of the statement wherever it stands, keeps its text exactly as written, since Snowflake stores the key's text and spacing, and the writer puts it back after the column list.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why did a staging table become permanent?
&lt;/h3&gt;

&lt;p&gt;Because &lt;code&gt;TRANSIENT&lt;/code&gt; was read and then dropped. A &lt;a href="https://docs.snowflake.com/en/user-guide/tables-temp-transient" rel="noopener noreferrer"&gt;transient table&lt;/a&gt; has no Fail-safe period and at most one day of Time Travel, so it costs less to keep and cannot be recovered once its Time Travel period of at most a day has passed; it is what raw and staging data is usually loaded into. The copy's &lt;code&gt;RAW_EVENTS&lt;/code&gt; came back as a permanent table, with Fail-safe storage the original never paid for. The diagram now keeps &lt;code&gt;TRANSIENT&lt;/code&gt; with the table's clustering key and writes &lt;code&gt;CREATE TRANSIENT TABLE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe23qby7chvh4lf63e5tg.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe23qby7chvh4lf63e5tg.webp" alt="View SQL of RAW_EVENTS in Schemity: CREATE TRANSIENT TABLE RAW_EVENTS with EVENT_ID, CUSTOMER_ID, PAYLOAD VARIANT, TAGS ARRAY, SOURCE with DEFAULT 'web' and LOADED_AT TIMESTAMP_NTZ(6) in the script's order, the primary key, and CLUSTER BY (LOADED_AT) after the column list, with no foreign key for CUSTOMER_ID" width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Why did TPC-DS's keys start numbering themselves?
&lt;/h3&gt;

&lt;p&gt;Because a Snowflake diagram did not record identity, and the writer guessed. It gave &lt;code&gt;AUTOINCREMENT&lt;/code&gt; to every single-column whole-number primary key, which is right for a table drawn in the editor and wrong for 17 of TPC-DS's 24 tables, whose surrogate keys come with the data being loaded; the other 7, the sales, returns and inventory tables, have composite keys. A row that leaves &lt;code&gt;CC_CALL_CENTER_SK&lt;/code&gt; out is refused by the original:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;100072 (22000): DML operation to table CALL_CENTER failed on column CC_CALL_CENTER_SK with error: NULL result in a non-nullable column
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The copy accepted it and gave it key 1, which the next load's real key 1 would then duplicate, and Snowflake would not refuse that either. The same missing record lost the feature schema's &lt;code&gt;ID NUMBER(38,0) autoincrement start 100 increment 5 noorder&lt;/code&gt;: written back as plain &lt;code&gt;AUTOINCREMENT&lt;/code&gt;, which starts at 1 and steps by 1, it numbered the first customers 1 and 2 where the original numbered them 100 and 105 (a &lt;code&gt;NOORDER&lt;/code&gt; identity does not promise consecutive values, but it starts where it is told). The diagram now records a Snowflake column's identity as declared, its start, step and &lt;code&gt;ORDER&lt;/code&gt; or &lt;code&gt;NOORDER&lt;/code&gt;, and records that a whole-number column has none, as it already did for PostgreSQL and SQL Server. Editing such a field in the editor keeps it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftb3sp38tzda3r60a5p53.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftb3sp38tzda3r60a5p53.webp" alt="View SQL of TPC-DS's CALL_CENTER in Schemity, the first 32 of its 36 lines: CREATE TABLE CALL_CENTER with CC_CALL_CENTER_SK NUMBER(38,0) NOT NULL and no AUTOINCREMENT, followed by the rest of its columns in their declared order" width="800" height="694"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Why did a case-insensitive email stop matching?
&lt;/h3&gt;

&lt;p&gt;Because the column's collation was read and not written. &lt;code&gt;EMAIL VARCHAR(255) COLLATE 'en-ci'&lt;/code&gt; compares &lt;a href="https://docs.snowflake.com/en/sql-reference/collation" rel="noopener noreferrer"&gt;without regard to case&lt;/a&gt;, so a lookup for &lt;code&gt;'A@X.COM'&lt;/code&gt; finds &lt;code&gt;'a@x.com'&lt;/code&gt;; the copy's plain &lt;code&gt;VARCHAR(255)&lt;/code&gt; compared case and found nothing, which is the difference between a login that works and one that does not. The writer now writes a Snowflake column's &lt;code&gt;COLLATE&lt;/code&gt; as declared.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9mhaxnx4ja6q6tbmqgtr.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9mhaxnx4ja6q6tbmqgtr.webp" alt="View SQL of CUSTOMERS in Schemity: ID NUMBER(38,0) AUTOINCREMENT START 100 INCREMENT 5 NOORDER NOT NULL, EMAIL VARCHAR(255) COLLATE 'en-ci' NOT NULL, the defaults 'US', 0, true and CURRENT_TIMESTAMP(), the primary key CUSTOMERS_PK and the unique key CUSTOMERS_EMAIL_UK, with no comments" width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What happened to the sequence?
&lt;/h3&gt;

&lt;p&gt;It was skipped, and the default that drew on it had two more problems. &lt;code&gt;ORDERS.ID&lt;/code&gt; defaults to &lt;code&gt;ORDER_SEQ.NEXTVAL&lt;/code&gt;, a sequence that starts at 1000 and steps by 10, and &lt;code&gt;GET_DDL&lt;/code&gt; writes that default with the original's database and schema, &lt;code&gt;SPIKE_DB.FEATURES_ORIG.ORDER_SEQ.NEXTVAL&lt;/code&gt;, so a copy elsewhere would draw on the original's sequence. And the writer, which &lt;code&gt;ORDERS&lt;/code&gt; never reached here, wrote such a default in quotes, as the text &lt;code&gt;'ORDER_SEQ.NEXTVAL'&lt;/code&gt;; the core now has a test for it. Import SQL now keeps each sequence the script creates as setup SQL, which the diagram's SQL runs first as &lt;code&gt;CREATE SEQUENCE IF NOT EXISTS&lt;/code&gt; with the script's own start and step; a default reads as the sequence alone; and the writer leaves a &lt;code&gt;NEXTVAL&lt;/code&gt; call unquoted. The copy's first order is 1000, as the original's.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhpq7vcxhnanfd2s8kee2.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhpq7vcxhnanfd2s8kee2.webp" alt="View SQL of ORDERS in Schemity: CREATE SEQUENCE IF NOT EXISTS ORDER_SEQ start with 1000 increment by 10 noorder, then CREATE TABLE ORDERS with ID NUMBER(38,0) NOT NULL DEFAULT ORDER_SEQ.NEXTVAL, the columns in the script's order, the primary key, the foreign key to CUSTOMERS, and CLUSTER BY (TO_DATE(ORDERED_AT)) after the column list" width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the round trip give you now?
&lt;/h2&gt;

&lt;p&gt;The same schema, for both, apart from two comments, from SQL that ran on Snowflake as Schemity wrote it. Neither script needed an edit or raised an error: TPC-DS's 24 &lt;code&gt;CREATE TABLE&lt;/code&gt; statements built all 24 tables, 425 columns, 24 primary keys and 105 foreign keys, and the feature schema's one &lt;code&gt;CREATE SEQUENCE&lt;/code&gt; and six &lt;code&gt;CREATE TABLE&lt;/code&gt; statements built its sequence, all 6 tables and every key.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;TPC-DS&lt;/th&gt;
&lt;th&gt;Feature schema&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Catalog differences&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;the table's comment and a column's comment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Probes that match the original&lt;/td&gt;
&lt;td&gt;2 of 2&lt;/td&gt;
&lt;td&gt;12 of 12&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All 24 of TPC-DS's clustering keys and its 17 surrogate keys without an identity come back as they were, as do the feature schema's expression clustering key, its transient table, its identity starting at 100 by 5, its case-insensitive collation and its sequence starting at 1000 by 10. The catalog includes each column's position, and every table comes back in its declared column order.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you see relationships in a Snowflake schema with no foreign keys?
&lt;/h2&gt;

&lt;p&gt;By reading them from the column names. Snowflake does not enforce a &lt;code&gt;FOREIGN KEY&lt;/code&gt; on a standard table, as the probes above rely on, so nothing stops a row that points nowhere, and plenty of Snowflake schemas declare no foreign keys at all. Raw landing tables and models built by ELT tools are the usual cases. Imported as written, such a schema is a grid of tables with no lines between them.&lt;/p&gt;

&lt;p&gt;The feature schema has one: &lt;code&gt;RAW_EVENTS.CUSTOMER_ID&lt;/code&gt; holds a customer's id, but the transient landing table declares no key for it. With &lt;strong&gt;Infer virtual relations from column names&lt;/strong&gt; ticked in Import SQL, Schemity matched &lt;code&gt;CUSTOMER_ID&lt;/code&gt; to &lt;code&gt;CUSTOMERS&lt;/code&gt; and its primary key &lt;code&gt;ID&lt;/code&gt;, and drew a dashed relation, zero-or-one at the customer end because the column allows &lt;code&gt;NULL&lt;/code&gt;. A &lt;a href="https://schemity.com/doc/virtual-relations/" rel="noopener noreferrer"&gt;virtual relation&lt;/a&gt; lives only in the diagram. The SQL Schemity writes leaves it out, so the round trip above is unchanged, and the diagram still shows where the data joins.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fec6hk6rsuzrxswjdbp3r.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fec6hk6rsuzrxswjdbp3r.webp" alt="The feature schema's diagram in Schemity: CUSTOMERS, PRODUCTS, ORDERS, ORDER_ITEMS, RAW_EVENTS and DAILY_REVENUE with their columns and defaults, solid relations for the three declared foreign keys, and a dashed relation from CUSTOMERS to RAW_EVENTS inferred from RAW_EVENTS.CUSTOMER_ID; the status bar reads 6 entities and 4 relations" width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What still differs, and why?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Comments.&lt;/strong&gt; The feature schema's table comment and its column comment on &lt;code&gt;LIFETIME_VALUE&lt;/code&gt; are read into the diagram as descriptions, and Schemity does not write descriptions to any database: they are the diagram's own notes, on Snowflake as on the other four. A copy carries the columns and keys, not the documentation, which stays in the diagram file.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this benchmark does not show
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The numbers through the app's window.&lt;/strong&gt; The catalog diffs and probes came from scripts calling Schemity's import, mapping and writer code on a development build. The screenshots are that build's app, importing the same &lt;code&gt;GET_DDL&lt;/code&gt; scripts, and the SQL file it exported for each diagram also ran by hand in a Snowflake worksheet with no error, building all 24 TPC-DS tables and all 6 of the feature schema's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A live connection.&lt;/strong&gt; Schemity has none to Snowflake; a schema comes in through Import SQL of &lt;code&gt;GET_DDL&lt;/code&gt;, and the diagram's SQL is run by you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data.&lt;/strong&gt; The originals are empty apart from the probes' rolled-back rows. A schema that matches is the precondition for loading data, not proof that a load is safe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;CHECK&lt;/code&gt; constraints, dynamic tables, views and stages.&lt;/strong&gt; Neither schema has a &lt;code&gt;CHECK&lt;/code&gt;; Snowflake enforces them, so they deserve their own test. Dynamic tables and views are not tables a diagram designs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Other databases.&lt;/strong&gt; The other four have their own posts, linked above, and this is the last in the series.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to rerun it
&lt;/h2&gt;

&lt;p&gt;Build each original in an empty schema, take &lt;code&gt;SELECT GET_DDL('schema', '&amp;lt;db&amp;gt;.&amp;lt;schema&amp;gt;')&lt;/code&gt; as the script, put it through Schemity's Import SQL, take the diagram's SQL from View SQL and run it in another empty schema. Then list both catalogs with this query, a shorter form of the benchmark's, and diff the output. It leaves out keys, sequences and comments, which the benchmark reads from &lt;code&gt;SHOW PRIMARY KEYS&lt;/code&gt;, &lt;code&gt;SHOW IMPORTED KEYS&lt;/code&gt; and &lt;code&gt;SHOW SEQUENCES&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'table | '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' | '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;is_transient&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' | '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clustering_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'no clustering key'&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;line&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tables&lt;/span&gt;
   &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CURRENT_SCHEMA&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;table_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'BASE TABLE'&lt;/span&gt;
  &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'column | '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' | '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;ordinal_position&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' | '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;column_name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' | '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;data_type&lt;/span&gt;
         &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' | '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;to_varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;character_maximum_length&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;to_varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;numeric_precision&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;','&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;to_varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;numeric_scale&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;to_varchar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;datetime_precision&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
         &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' | '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;is_nullable&lt;/span&gt;
         &lt;span class="c1"&gt;-- A default drawing on a sequence names the schema it is in.&lt;/span&gt;
         &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' | '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;column_default&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current_database&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'.'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;current_schema&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'.'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'no default'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
         &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' | identity '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;identity_start&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;','&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;identity_increment&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;','&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;identity_ordered&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'none'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
         &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' | '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;collation_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'no collation'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;
   &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;CURRENT_SCHEMA&lt;/span&gt;&lt;span class="p"&gt;()&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;line&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For behaviour, run each probe between &lt;code&gt;BEGIN&lt;/code&gt; and &lt;code&gt;ROLLBACK&lt;/code&gt;, end it with a &lt;code&gt;SELECT&lt;/code&gt; of what was stored, and rebuild the schema before running the probes again: an identity and a sequence keep the numbers a rolled-back transaction drew.&lt;/p&gt;

</description>
      <category>snowflake</category>
      <category>database</category>
      <category>datawarehouse</category>
      <category>sql</category>
    </item>
    <item>
      <title>Round-Tripping Campfire and Jellyfin Through a Database Diagram: What SQLite Lost and What We Fixed</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Tue, 20 Oct 2026 14:53:07 +0000</pubDate>
      <link>https://dev.to/tbson87/round-tripping-campfire-and-jellyfin-through-a-database-diagram-what-sqlite-lost-and-what-we-fixed-5apb</link>
      <guid>https://dev.to/tbson87/round-tripping-campfire-and-jellyfin-through-a-database-diagram-what-sqlite-lost-and-what-we-fixed-5apb</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://schemity.com" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt;, a desktop ERD tool - this post is from our blog and uses it for the examples.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; We put two real SQLite schemas, Campfire (Rails 8) and Jellyfin (Entity Framework Core 10), through Schemity and rebuilt a database file from the SQL it wrote. Before this round of fixes Campfire's columns came back as an affinity name instead of their declared type, so Rails read &lt;code&gt;created_at&lt;/code&gt; as a decimal and Campfire could not create an account; Jellyfin's partial and expression indexes were lost. After the fixes Jellyfin comes back with no difference, Campfire differs only in its FTS5 search table, Campfire's own code works on the copies, and all 18 behaviour probes across both schemas match the originals.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A database diagram that reads a SQLite schema and writes it back should give you the same database file, and before this round of fixes Schemity did not. We took two real open-source schemas, Campfire v1.5.2 (Rails 8, 23 tables) and Jellyfin 12.2 (Entity Framework Core 10, 33 tables), put each through Schemity from a &lt;code&gt;.schema&lt;/code&gt; script and from the live database file, and rebuilt a new file from the SQL Schemity wrote. SQLite stored the same values in the copies, so most row-level checks passed, but Campfire's columns came back as an affinity name instead of their declared type, and Campfire's own code could not create an account on the copy. After the fixes, Jellyfin comes back with no catalog difference on either path, Campfire differs only in its FTS5 search table, and both behave like their originals.&lt;/p&gt;

&lt;p&gt;Schemity is database design software that reads your live database, shows the impact of every schema change before it runs, and keeps the diagram as a file in Git. This is the fourth post in a series that tests the round trip on each database it supports, after &lt;a href="https://schemity.com/blog/postgres-round-trip-mastodon-saleor/" rel="noopener noreferrer"&gt;Mastodon and Saleor on PostgreSQL&lt;/a&gt;, &lt;a href="https://schemity.com/blog/mysql-round-trip-invoice-ninja-sylius/" rel="noopener noreferrer"&gt;Invoice Ninja and Sylius on MySQL&lt;/a&gt; and &lt;a href="https://schemity.com/blog/sqlserver-round-trip-bitwarden-smartstore/" rel="noopener noreferrer"&gt;Bitwarden and Smartstore on SQL Server&lt;/a&gt;. Reading a schema you did not write is how you understand your schema before changing it, and SQLite tested something the other three never did: a database that accepts any type name and keeps it, while enforcing almost none of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which two schemas, and which two paths?
&lt;/h2&gt;

&lt;p&gt;Two schemas built by two frameworks that treat SQLite differently. Campfire, 37signals' self-hosted chat app, comes from Rails migrations, which declare types the way Rails reads them back: &lt;code&gt;datetime(6)&lt;/code&gt;, &lt;code&gt;varchar&lt;/code&gt;, &lt;code&gt;bigint&lt;/code&gt;, &lt;code&gt;json&lt;/code&gt;. Jellyfin, the media server, comes from Entity Framework Core migrations, which declare only SQLite's own names (&lt;code&gt;INTEGER&lt;/code&gt;, &lt;code&gt;TEXT&lt;/code&gt;, &lt;code&gt;REAL&lt;/code&gt;) but use partial and expression indexes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Campfire v1.5.2&lt;/th&gt;
&lt;th&gt;Jellyfin 12.2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What it is&lt;/td&gt;
&lt;td&gt;Group chat&lt;/td&gt;
&lt;td&gt;Media server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Built by&lt;/td&gt;
&lt;td&gt;Rails 8 migrations, &lt;code&gt;bin/rails db:prepare&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Entity Framework Core 10 migrations, on first start&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tables&lt;/td&gt;
&lt;td&gt;23: 17 of its own, an FTS5 virtual table and the 5 tables SQLite keeps its data in&lt;/td&gt;
&lt;td&gt;33&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Foreign keys&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;28, 25 of them &lt;code&gt;ON DELETE CASCADE&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;AUTOINCREMENT&lt;/code&gt; keys&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;12, and one plain &lt;code&gt;INTEGER PRIMARY KEY&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Declared types beyond SQLite's own&lt;/td&gt;
&lt;td&gt;33 &lt;code&gt;varchar&lt;/code&gt;, 1 &lt;code&gt;varchar(16)&lt;/code&gt;, 31 &lt;code&gt;datetime(6)&lt;/code&gt;, 6 &lt;code&gt;bigint&lt;/code&gt;, 1 &lt;code&gt;json&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Partial / expression indexes&lt;/td&gt;
&lt;td&gt;0 / 0&lt;/td&gt;
&lt;td&gt;4 / 1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each schema went through Schemity on two paths:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Import SQL:&lt;/strong&gt; the sqlite3 shell's &lt;code&gt;.schema&lt;/code&gt; output, read by Schemity's SQL parser, with no connection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live connection:&lt;/strong&gt; Schemity's SQLite reader opened the original database file and read SQLite's &lt;code&gt;PRAGMA&lt;/code&gt; functions and &lt;code&gt;sqlite_master&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both paths end with the diagram's SQL applied to an empty file with the sqlite3 shell. The runs used Schemity's own code from a development build, called from scripts rather than through the app's window. The fixes described below are in that build and ship in the next release; until then, the released app has the type and index losses in the "before" column.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you prove a rebuilt SQLite schema is the same?
&lt;/h2&gt;

&lt;p&gt;Three checks, one more than the earlier posts, because on SQLite the database itself barely notices the difference that mattered most.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The catalog diff.&lt;/strong&gt; A script lists one line per fact for the original file and for the copy: every table with &lt;code&gt;WITHOUT ROWID&lt;/code&gt;, &lt;code&gt;STRICT&lt;/code&gt; and &lt;code&gt;AUTOINCREMENT&lt;/code&gt;; every column with its declared type, nullability, default and key position; every foreign key with its actions; every index with its keys, &lt;code&gt;WHERE&lt;/code&gt; and expressions. The lists are diffed line by line. A declared type is compared without regard to case, as SQLite and Rails read it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The behaviour probes.&lt;/strong&gt; A probe is a few statements in one transaction that is rolled back, run with &lt;code&gt;PRAGMA foreign_keys = ON&lt;/code&gt; as both frameworks open their connections: delete an account and insert another to see which id it gets, store &lt;code&gt;'12'&lt;/code&gt; in the &lt;code&gt;json&lt;/code&gt; column and ask SQLite what type it kept, ask SQLite how it plans a lookup by &lt;code&gt;lower("Name")&lt;/code&gt;. The outcome is "accepted" with what the &lt;code&gt;SELECT&lt;/code&gt; read back, or "refused at step N" with SQLite's message.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The framework itself.&lt;/strong&gt; Campfire's Docker image ran &lt;code&gt;bin/rails runner&lt;/code&gt; against each copy: print the type Rails reads for three columns of &lt;code&gt;accounts&lt;/code&gt; (&lt;code&gt;created_at&lt;/code&gt;, &lt;code&gt;settings&lt;/code&gt;, &lt;code&gt;join_code&lt;/code&gt;), then create an account through Campfire's own &lt;code&gt;Account&lt;/code&gt; model and read it back.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;What it compares&lt;/th&gt;
&lt;th&gt;What it catches&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Catalog diff&lt;/td&gt;
&lt;td&gt;Tables, columns, keys, indexes, as SQLite reports them&lt;/td&gt;
&lt;td&gt;Anything missing, added or changed in the schema&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Probes&lt;/td&gt;
&lt;td&gt;What each file accepts, refuses, stores and how it plans a query&lt;/td&gt;
&lt;td&gt;A key, index or affinity that behaves differently&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rails runner&lt;/td&gt;
&lt;td&gt;What the application reads from the schema&lt;/td&gt;
&lt;td&gt;A declared type the application reads differently&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The originals hold a few rows the frameworks wrote (Campfire's &lt;code&gt;schema_migrations&lt;/code&gt; and &lt;code&gt;ar_internal_metadata&lt;/code&gt;, Jellyfin's &lt;code&gt;__EFMigrationsHistory&lt;/code&gt; and 4 rows of library items); every probe uses rows of its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  What failed before the fixes?
&lt;/h2&gt;

&lt;p&gt;Everything below is from Schemity's code just before these fixes (commit &lt;code&gt;cb4b7b28&lt;/code&gt;), on both paths.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Campfire, Import SQL&lt;/th&gt;
&lt;th&gt;Campfire, live&lt;/th&gt;
&lt;th&gt;Jellyfin, Import SQL&lt;/th&gt;
&lt;th&gt;Jellyfin, live&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Catalog lines that differ&lt;/td&gt;
&lt;td&gt;198&lt;/td&gt;
&lt;td&gt;200&lt;/td&gt;
&lt;td&gt;37&lt;/td&gt;
&lt;td&gt;37&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Declared types written as an affinity name&lt;/td&gt;
&lt;td&gt;72&lt;/td&gt;
&lt;td&gt;72&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;INTEGER PRIMARY KEY&lt;/code&gt; without its &lt;code&gt;NOT NULL&lt;/code&gt; (spelling only)&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;td&gt;13&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;FTS5 table and its data tables&lt;/td&gt;
&lt;td&gt;data tables read as Campfire's&lt;/td&gt;
&lt;td&gt;all 6 read as plain tables&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Partial indexes that lost &lt;code&gt;WHERE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;4 of 4&lt;/td&gt;
&lt;td&gt;4 of 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expression index&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;left out&lt;/td&gt;
&lt;td&gt;left out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;AUTOINCREMENT&lt;/code&gt; added where there was none&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Probes that match the original&lt;/td&gt;
&lt;td&gt;10 of 10&lt;/td&gt;
&lt;td&gt;10 of 10&lt;/td&gt;
&lt;td&gt;5 of 8&lt;/td&gt;
&lt;td&gt;5 of 8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Campfire's &lt;code&gt;Account.create!&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;fails&lt;/td&gt;
&lt;td&gt;fails&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cascades, unique indexes, defaults, &lt;code&gt;NULL&lt;/code&gt; handling in unique columns, composite keys and foreign keys all survived. The failures:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Original&lt;/th&gt;
&lt;th&gt;Before the fixes&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Campfire: Rails reads &lt;code&gt;accounts.created_at&lt;/code&gt; as&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;datetime&lt;/code&gt;, precision 6&lt;/td&gt;
&lt;td&gt;&lt;code&gt;decimal&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;datetime&lt;/code&gt;, precision 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Campfire: &lt;code&gt;Account.create!&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;creates the account&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NoMethodError&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;creates the account&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jellyfin: plan for &lt;code&gt;WHERE lower(Name) = 'x'&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SEARCH Peoples USING INDEX IX_Peoples_NameLower&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SCAN Peoples&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the original's&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jellyfin: plan for &lt;code&gt;WHERE OwnerId IS NULL&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SCAN BaseItems&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;SEARCH ... USING INDEX IX_BaseItems_OwnerId&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the original's&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jellyfin: a deleted migration lock's id&lt;/td&gt;
&lt;td&gt;handed out again: 1&lt;/td&gt;
&lt;td&gt;not reused: 2&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Why does a declared type matter if SQLite ignores it?
&lt;/h3&gt;

&lt;p&gt;Because SQLite does not ignore it; it keeps it and reads only part of it. A column's declared type sets its affinity by &lt;a href="https://www.sqlite.org/datatype3.html" rel="noopener noreferrer"&gt;five substring rules&lt;/a&gt;: a name containing &lt;code&gt;INT&lt;/code&gt; gets &lt;code&gt;INTEGER&lt;/code&gt;, one containing &lt;code&gt;CHAR&lt;/code&gt;, &lt;code&gt;CLOB&lt;/code&gt; or &lt;code&gt;TEXT&lt;/code&gt; gets &lt;code&gt;TEXT&lt;/code&gt;, and so on, down to &lt;code&gt;NUMERIC&lt;/code&gt; for anything else, including &lt;code&gt;datetime&lt;/code&gt; and &lt;code&gt;json&lt;/code&gt;. The length in &lt;code&gt;varchar(16)&lt;/code&gt; is ignored. Schemity's SQLite support modelled only the five affinity names, so both paths turned &lt;code&gt;varchar&lt;/code&gt; into &lt;code&gt;TEXT&lt;/code&gt;, &lt;code&gt;datetime(6)&lt;/code&gt; into &lt;code&gt;NUMERIC&lt;/code&gt; and &lt;code&gt;bigint&lt;/code&gt; into &lt;code&gt;INTEGER&lt;/code&gt;. Each pair has the same affinity, so SQLite stores the same values in the copy, and every SQL probe passed.&lt;/p&gt;

&lt;p&gt;Rails reads the declared text. On the copy, &lt;code&gt;bin/rails runner&lt;/code&gt; printed &lt;code&gt;created_at: NUMERIC -&amp;gt; decimal&lt;/code&gt;, &lt;code&gt;settings: NUMERIC -&amp;gt; decimal&lt;/code&gt; and &lt;code&gt;join_code: TEXT -&amp;gt; text&lt;/code&gt;, where the original says &lt;code&gt;datetime(6) -&amp;gt; datetime, precision 6&lt;/code&gt;, &lt;code&gt;json -&amp;gt; json&lt;/code&gt; and &lt;code&gt;varchar -&amp;gt; string&lt;/code&gt;. Campfire's &lt;code&gt;Account&lt;/code&gt; model keeps its settings in that &lt;code&gt;json&lt;/code&gt; column, and creating an account failed with &lt;code&gt;NoMethodError: undefined method 'reverse_merge!' for nil&lt;/code&gt;. Campfire deployed against a schema rebuilt by the diagram would have stopped at its first account, and any Rails app would have read its times as decimals.&lt;/p&gt;

&lt;p&gt;The diagram now keeps each column's declared type on both paths, with its length or precision, and writes it back. The &lt;a href="https://schemity.com/doc/tables-and-fields/" rel="noopener noreferrer"&gt;field editor&lt;/a&gt; offers the declared types frameworks write (&lt;code&gt;VARCHAR&lt;/code&gt;, &lt;code&gt;CHAR&lt;/code&gt;, &lt;code&gt;BIGINT&lt;/code&gt;, &lt;code&gt;INT&lt;/code&gt;, &lt;code&gt;SMALLINT&lt;/code&gt;, &lt;code&gt;BOOLEAN&lt;/code&gt;, &lt;code&gt;DECIMAL&lt;/code&gt;, &lt;code&gt;DOUBLE&lt;/code&gt;, &lt;code&gt;FLOAT&lt;/code&gt;, &lt;code&gt;DATE&lt;/code&gt;, &lt;code&gt;TIME&lt;/code&gt;, &lt;code&gt;DATETIME&lt;/code&gt;, &lt;code&gt;JSON&lt;/code&gt;) beside the five affinity names. A diagram drawn before this change holds &lt;code&gt;TEXT&lt;/code&gt; where the database says &lt;code&gt;varchar&lt;/code&gt;; saving it does not rebuild the table for that, since the two have the same affinity, and a rebuild for another reason keeps the database's declared type. Lint compares foreign key types by affinity on SQLite, so a &lt;code&gt;bigint&lt;/code&gt; column pointing at an &lt;code&gt;integer&lt;/code&gt; key is not reported as a mismatch.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqbz7alochwmanpq4wf7z.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqbz7alochwmanpq4wf7z.webp" alt="Part of the Campfire diagram read from the live database file, with the Accounts &amp;amp; Users and Rooms &amp;amp; Messages legends in view and the minimap in the corner: accounts shows join_code and name as VARCHAR, settings as JSON and created_at as DATETIME(6), rooms.creator_id is BIGINT and boosts.content VARCHAR(16); the status bar reads Entities: 17, Relations: 10 (+2 virtual hidden)" width="800" height="527"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What happened to Jellyfin's partial and expression indexes?
&lt;/h3&gt;

&lt;p&gt;They lost what made them different. Jellyfin has four partial indexes, such as &lt;code&gt;IX_BaseItems_OwnerId ON BaseItems (OwnerId) WHERE OwnerId IS NOT NULL&lt;/code&gt;, and one on an expression, &lt;code&gt;IX_Peoples_NameLower ON Peoples (lower("Name"))&lt;/code&gt;. Both paths wrote the partial ones without &lt;code&gt;WHERE&lt;/code&gt; and left the expression index out; the live reader had kept them only to replay during a table rebuild.&lt;/p&gt;

&lt;p&gt;None of the five is unique, so no row is refused either way. The query plan shows the difference. On the copy without the expression index, SQLite planned a lookup by &lt;code&gt;lower(Name)&lt;/code&gt; as a full scan of &lt;code&gt;Peoples&lt;/code&gt;; on the original it searches the index. And &lt;code&gt;IX_BaseItems_OwnerId&lt;/code&gt;, written as a plain index, holds every item instead of only those with an owner, so SQLite keeps it up to date on every write to an ownerless item and plans &lt;code&gt;WHERE OwnerId IS NULL&lt;/code&gt; through it, where the original scans the table. The diagram now keeps a SQLite index's key expressions and &lt;code&gt;WHERE&lt;/code&gt;, as it already did for PostgreSQL, and reads them through the same parser on both paths.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmg1jehrfmevcpew2igkw.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmg1jehrfmevcpew2igkw.webp" alt="The Indexes tab of Jellyfin's BaseItems in Schemity: IX_BaseItems_OwnerId on OwnerId WHERE " width="800" height="681"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3ebp02xni5utda4mopcs.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3ebp02xni5utda4mopcs.webp" alt="Schemity's index dialog for Jellyfin's IX_Peoples_NameLower, with Keys lower(" width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Why did a lock table start skipping ids?
&lt;/h3&gt;

&lt;p&gt;Because &lt;code&gt;AUTOINCREMENT&lt;/code&gt; changes which ids a table hands out, and the writer added it to every table. &lt;a href="https://www.sqlite.org/autoinc.html" rel="noopener noreferrer"&gt;SQLite's documentation&lt;/a&gt; puts it plainly: a plain &lt;code&gt;INTEGER PRIMARY KEY&lt;/code&gt; gives a new row the largest id plus one, so a deleted last id comes back, while &lt;code&gt;AUTOINCREMENT&lt;/code&gt; never reuses one and records the highest in &lt;code&gt;sqlite_sequence&lt;/code&gt;. Jellyfin declares it on 12 tables and leaves it off &lt;code&gt;__EFMigrationsLock&lt;/code&gt;. The copy's lock table gave a new row id 2 after id 1 was deleted, where the original gives 1 again. Schemity now records &lt;code&gt;AUTOINCREMENT&lt;/code&gt;, &lt;code&gt;WITHOUT ROWID&lt;/code&gt; and &lt;code&gt;STRICT&lt;/code&gt; for each table on both paths and writes them as declared; neither schema has a table of its own with the last two, so only unit tests cover them.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is an FTS5 virtual table to a diagram?
&lt;/h3&gt;

&lt;p&gt;Not a table it can hold. Campfire's message search is &lt;code&gt;CREATE VIRTUAL TABLE message_search_index USING fts5 (body, tokenize=porter)&lt;/code&gt;, and SQLite creates five tables of its own to keep the index in: &lt;code&gt;message_search_index_data&lt;/code&gt;, &lt;code&gt;_idx&lt;/code&gt;, &lt;code&gt;_content&lt;/code&gt;, &lt;code&gt;_docsize&lt;/code&gt; and &lt;code&gt;_config&lt;/code&gt;. Import SQL could not parse the virtual table and imported the five as if Campfire had written them; the live reader read all six as plain tables, so the copy got a &lt;code&gt;message_search_index&lt;/code&gt; table with a &lt;code&gt;body&lt;/code&gt; column and no search. Import SQL now names the virtual table in its summary and leaves it out with its five data tables, and the live reader skips both kinds.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiikvsq86qj1uc9ipoolv.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fiikvsq86qj1uc9ipoolv.webp" alt="Schemity's Import SQL summary for Campfire's .schema on an empty diagram: the last rows of the table list, each marked New, the note that 1 virtual table is not imported, nor the tables SQLite keeps its data in, and the diagram's SQL does not create it: message_search_index, and the button Import (17 new, 0 updated)" width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Which bugs did only a refresh find?
&lt;/h3&gt;

&lt;p&gt;Import a script, connect to the file it came from, and &lt;a href="https://schemity.com/doc/resync-database/" rel="noopener noreferrer"&gt;Refresh &amp;amp; merge&lt;/a&gt; should find nothing. Through the app's own merge code, it planned 152 statements for Campfire and 503 for Jellyfin, mostly table rebuilds. Foreign key names differed on 9 of Campfire's tables and 22 of Jellyfin's. SQLite keeps a foreign key's name only in the table's DDL: Import SQL read &lt;code&gt;fk_rails_c3b3935057&lt;/code&gt; and &lt;code&gt;FK_Devices_Users_UserId&lt;/code&gt; from the script, while the live reader named each key after its columns. The live reader now takes a name the DDL declares, the writer writes it as &lt;code&gt;CONSTRAINT "name" FOREIGN KEY&lt;/code&gt;, and both refreshes list nothing, as do the six schemas from the earlier posts.&lt;/p&gt;

&lt;p&gt;Taking the screenshots for this post turned up one more, which the scripts could not see because they skip a step the app takes when it reads a table: the app lists a table's fields key first, foreign keys next and timestamps last, and wrote &lt;code&gt;CREATE TABLE&lt;/code&gt; in that order. A copy of Campfire's &lt;code&gt;sessions&lt;/code&gt; built from the diagram put &lt;code&gt;user_id&lt;/code&gt; second instead of last, so a &lt;code&gt;SELECT *&lt;/code&gt; or an &lt;code&gt;INSERT&lt;/code&gt; without a column list would read and write its columns in a different order, and a SQLite table rebuild writes the same &lt;code&gt;CREATE TABLE&lt;/code&gt;. The diagram now remembers each column's position as the table declares it and writes that order, while the canvas keeps its own; a column added in the diagram goes last, where &lt;code&gt;ADD COLUMN&lt;/code&gt; puts it. Read and written back the way the app does it, 14 of Campfire's 17 tables and 20 of Jellyfin's 33 came back in a different column order before this fix, and none do now.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu8aavuzalhlwxlpuomfu.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu8aavuzalhlwxlpuomfu.webp" alt="Schemity's Entity SQL dialog for Campfire's sessions: CREATE TABLE " width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the round trip give you now?
&lt;/h2&gt;

&lt;p&gt;The same file, on both paths, for both schemas, apart from the search table.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Import SQL&lt;/th&gt;
&lt;th&gt;Live connection&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Campfire: catalog differences&lt;/td&gt;
&lt;td&gt;the FTS5 table and its 5 data tables&lt;/td&gt;
&lt;td&gt;the same&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Campfire: probes that match&lt;/td&gt;
&lt;td&gt;10 of 10&lt;/td&gt;
&lt;td&gt;10 of 10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Campfire: Rails types and &lt;code&gt;Account.create!&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;as the original&lt;/td&gt;
&lt;td&gt;as the original&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jellyfin: catalog differences&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jellyfin: probes that match&lt;/td&gt;
&lt;td&gt;8 of 8&lt;/td&gt;
&lt;td&gt;8 of 8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All 72 of Campfire's declared types, the 28 &lt;code&gt;NOT NULL&lt;/code&gt; rowid keys, Jellyfin's 4 partial indexes and its expression index, and every table's &lt;code&gt;AUTOINCREMENT&lt;/code&gt; come back as they were.&lt;/p&gt;

&lt;p&gt;In the app's window, Import SQL of Campfire's script onto the diagram read from its live file finds nothing to change.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fegjznjr85r5qg700wo0h.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fegjznjr85r5qg700wo0h.webp" alt="Schemity's Import SQL summary for Campfire's .schema on the diagram read from the live database file: the last rows of the table list, each marked Unchanged, the note that the virtual table message_search_index is not imported, and the button Import (0 new, 0 updated) disabled" width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What still differs, and why?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Virtual tables.&lt;/strong&gt; FTS3, FTS4, FTS5 and R*Tree tables belong to a SQLite module, not to columns a diagram can hold, so the diagram's SQL does not create Campfire's &lt;code&gt;message_search_index&lt;/code&gt;. Run its &lt;code&gt;CREATE VIRTUAL TABLE&lt;/code&gt; from the script after the diagram's SQL and SQLite creates the five data tables itself; with that one statement added, Campfire's copy matches the original line for line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spelling.&lt;/strong&gt; A declared type is written in upper case (&lt;code&gt;DATETIME(6)&lt;/code&gt; for &lt;code&gt;datetime(6)&lt;/code&gt;), which SQLite and Rails read the same way. An &lt;code&gt;INTEGER PRIMARY KEY&lt;/code&gt; declared without &lt;code&gt;NOT NULL&lt;/code&gt; gets it, which a rowid key is anyway; both schemas already declare it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this benchmark does not show
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The app's window.&lt;/strong&gt; These runs called Schemity's import, reader, export and comparison code from scripts on a development build. The screenshots are from the app's window on the same build, and the column-order check above went through the app's own read path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data.&lt;/strong&gt; The originals hold only bookkeeping rows. A schema that matches is the precondition for moving data, not proof that a migration of a full database is safe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Triggers, views and &lt;code&gt;CHECK&lt;/code&gt; constraints.&lt;/strong&gt; Neither schema has one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Snowflake.&lt;/strong&gt; The last post in the series puts &lt;a href="https://schemity.com/blog/snowflake-round-trip-tpcds/" rel="noopener noreferrer"&gt;TPC-DS and a Snowflake feature schema&lt;/a&gt; through the same round trip.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Other frameworks.&lt;/strong&gt; Django and Prisma declare SQLite types their own way; this tested Rails and EF Core.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to rerun it
&lt;/h2&gt;

&lt;p&gt;Create each schema with its framework against an empty file, take &lt;code&gt;sqlite3 &amp;lt;file&amp;gt; .schema&lt;/code&gt; for the script, put the script or the file through Schemity, export the SQL and apply it to an empty file. Then list both catalogs with this query, a shorter form of the benchmark's, and diff the output. It lists a primary key or &lt;code&gt;UNIQUE&lt;/code&gt; constraint's index by origin only and leaves collations out, which the benchmark's script compares:&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;line&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'table | '&lt;/span&gt; &lt;span class="o"&gt;||&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;name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' | '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt;
         &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&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;wr&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;' | without rowid'&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;
         &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;strict&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;' | strict'&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;
         &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;sql&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%AUTOINCREMENT%'&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;' | autoincrement'&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pragma_table_list&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;sqlite_schema&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&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="k"&gt;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'main'&lt;/span&gt; &lt;span class="k"&gt;AND&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;name&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'sqlite_%'&lt;/span&gt;
  &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'column | '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' | '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' | '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
         &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' | '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"notnull"&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;'not null'&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="s1"&gt;'null'&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;
         &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' | '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;ifnull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'default '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dflt_value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'no default'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' | pk '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pk&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sqlite_schema&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pragma_table_xinfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
   &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'table'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'sqlite_%'&lt;/span&gt;
  &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'fk | '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' | '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"from"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' | '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"table"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' | '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"to"&lt;/span&gt;
         &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' | on delete '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;on_delete&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' | on update '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;on_update&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sqlite_schema&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pragma_foreign_key_list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;
   &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'table'&lt;/span&gt;
  &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'index | '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' | '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' | unique '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"unique"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' | '&lt;/span&gt;
         &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;ifnull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'"'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sqlite_schema&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pragma_index_list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;
    &lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;sqlite_schema&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;
   &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'table'&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;line&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It needs SQLite 3.37 or later for &lt;code&gt;pragma_table_list&lt;/code&gt;. For behaviour, run each probe between &lt;code&gt;BEGIN&lt;/code&gt; and &lt;code&gt;ROLLBACK&lt;/code&gt; with &lt;code&gt;PRAGMA foreign_keys = ON&lt;/code&gt;, end it with a &lt;code&gt;SELECT&lt;/code&gt; of what was stored or an &lt;code&gt;EXPLAIN QUERY PLAN&lt;/code&gt;, and compare the outputs. And if a framework reads your schema, ask it: on SQLite the declared type is for the application more than for the database.&lt;/p&gt;

</description>
      <category>sqlite</category>
      <category>rails</category>
      <category>dotnet</category>
      <category>database</category>
    </item>
    <item>
      <title>Round-Tripping Bitwarden and Smartstore Through a Database Diagram: What SQL Server Lost and What We Fixed</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Tue, 20 Oct 2026 14:52:31 +0000</pubDate>
      <link>https://dev.to/tbson87/round-tripping-bitwarden-and-smartstore-through-a-database-diagram-what-sql-server-lost-and-what-2nk0</link>
      <guid>https://dev.to/tbson87/round-tripping-bitwarden-and-smartstore-through-a-database-diagram-what-sql-server-lost-and-what-2nk0</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://schemity.com" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt;, a desktop ERD tool - this post is from our blog and uses it for the examples.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; We put two real SQL Server schemas, Bitwarden (a SQL Server database project) and Smartstore (Entity Framework Core), through Schemity and rebuilt a database from the SQL it wrote. Before this round of fixes Bitwarden's script failed, &lt;code&gt;varchar&lt;/code&gt; came back as &lt;code&gt;nvarchar&lt;/code&gt;, the live path turned &lt;code&gt;tinyint&lt;/code&gt;, &lt;code&gt;money&lt;/code&gt; and &lt;code&gt;datetime&lt;/code&gt; into wider types, and filtered indexes lost their &lt;code&gt;WHERE&lt;/code&gt;, so a copy refused rows the original accepts. After the fixes Smartstore comes back with no difference, Bitwarden differs only in two foreign keys created &lt;code&gt;WITH NOCHECK&lt;/code&gt;, and 21 behaviour probes match.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A database diagram that reads a SQL Server schema and writes it back should give you the same database, and before this round of fixes Schemity did not. We took two real open-source schemas, Bitwarden server v2026.10.0 (a SQL Server database project, 64 tables) and Smartstore 6.4.0 (Entity Framework Core, 118 tables), put each through Schemity from a script and from the live database, and rebuilt a database from the SQL Schemity wrote. Bitwarden's script failed, every &lt;code&gt;varchar&lt;/code&gt; came back as &lt;code&gt;nvarchar&lt;/code&gt;, and the copies accepted and refused different rows from the originals. After the fixes, Smartstore comes back with no catalog difference at all on either path, Bitwarden differs only in two foreign keys it created &lt;code&gt;WITH NOCHECK&lt;/code&gt;, and both behave exactly like their originals in 21 behaviour probes.&lt;/p&gt;

&lt;p&gt;Schemity is database design software that reads your live database, shows the impact of every schema change before it runs, and keeps the diagram as a file in Git. This is the third post in a series that tests the round trip on each database it supports, after &lt;a href="https://schemity.com/blog/postgres-round-trip-mastodon-saleor/" rel="noopener noreferrer"&gt;Mastodon and Saleor on PostgreSQL&lt;/a&gt; and &lt;a href="https://schemity.com/blog/mysql-round-trip-invoice-ninja-sylius/" rel="noopener noreferrer"&gt;Invoice Ninja and Sylius on MySQL&lt;/a&gt;. Reading a schema you did not write is how you understand your schema before changing it, and SQL Server tested the parts of the diagram the other two never reached: its own type names, and indexes that carry more than their key columns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which two schemas, and which two paths?
&lt;/h2&gt;

&lt;p&gt;Two schemas built two different ways. Bitwarden's comes from a SQL Server database project (&lt;code&gt;Sql.sqlproj&lt;/code&gt;) applied by its own migrator, so it is hand-written T-SQL with procedures and views. Smartstore's comes from its installer, which runs on Entity Framework Core with FluentMigrator.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Bitwarden server v2026.10.0&lt;/th&gt;
&lt;th&gt;Smartstore 6.4.0&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What it is&lt;/td&gt;
&lt;td&gt;Password manager&lt;/td&gt;
&lt;td&gt;E-commerce platform&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Built by&lt;/td&gt;
&lt;td&gt;SQL Server database project, Bitwarden's migrator&lt;/td&gt;
&lt;td&gt;Entity Framework Core and FluentMigrator, the web installer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tables&lt;/td&gt;
&lt;td&gt;64&lt;/td&gt;
&lt;td&gt;118&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Columns&lt;/td&gt;
&lt;td&gt;705&lt;/td&gt;
&lt;td&gt;1,225&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Foreign keys&lt;/td&gt;
&lt;td&gt;88: 43 &lt;code&gt;ON DELETE CASCADE&lt;/code&gt;, 2 &lt;code&gt;ON DELETE SET NULL&lt;/code&gt;, 2 &lt;code&gt;WITH NOCHECK&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;122: 86 &lt;code&gt;ON DELETE CASCADE&lt;/code&gt;, 20 &lt;code&gt;ON DELETE SET NULL&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Indexes besides primary keys&lt;/td&gt;
&lt;td&gt;117&lt;/td&gt;
&lt;td&gt;260&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Filtered (&lt;code&gt;WHERE&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;22, 3 of them unique&lt;/td&gt;
&lt;td&gt;5, all unique&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Covering (&lt;code&gt;INCLUDE&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;19&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;With a &lt;code&gt;DESC&lt;/code&gt; key&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keys&lt;/td&gt;
&lt;td&gt;mostly &lt;code&gt;uniqueidentifier&lt;/code&gt;, 4 &lt;code&gt;IDENTITY&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;104 &lt;code&gt;IDENTITY&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;varchar&lt;/code&gt; / &lt;code&gt;tinyint&lt;/code&gt; / &lt;code&gt;money&lt;/code&gt; / &lt;code&gt;datetime&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;75 / 47 / 3 / 3&lt;/td&gt;
&lt;td&gt;0 / 0 / 0 / 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;CHECK&lt;/code&gt; / defaults / unique constraints&lt;/td&gt;
&lt;td&gt;1 / 40 / 5&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Also has&lt;/td&gt;
&lt;td&gt;61 views, 574 procedures, 3 functions, 5 table types&lt;/td&gt;
&lt;td&gt;nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;SQL Server has no &lt;code&gt;pg_dump&lt;/code&gt; or &lt;code&gt;mysqldump&lt;/code&gt;. The script for the Import SQL path came from sqlpackage, Microsoft's command-line tool, with &lt;code&gt;/a:Extract /p:ExtractTarget=File&lt;/code&gt;, which writes the whole schema as one &lt;code&gt;.sql&lt;/code&gt; file. Each schema then went through Schemity on two paths:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Import SQL:&lt;/strong&gt; that script, read by Schemity's SQL parser, with no connection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live connection:&lt;/strong&gt; Schemity's SQL Server reader connected to the original database and read its catalog views.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both paths end with the diagram's SQL applied to an empty database on the same SQL Server 2022 server. The runs used Schemity's own code from a development build, called from scripts rather than through the app's window. The fixes described below are in that build and ship in the next release; until then, the released app has the same type and index losses as the "before" column.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you prove a rebuilt SQL Server schema is the same?
&lt;/h2&gt;

&lt;p&gt;The same two checks as the MySQL post.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The catalog diff.&lt;/strong&gt; A query over SQL Server's catalog views (&lt;code&gt;sys.columns&lt;/code&gt;, &lt;code&gt;sys.indexes&lt;/code&gt;, &lt;code&gt;sys.foreign_keys&lt;/code&gt; and the rest) lists one line per fact for the original and for the copy: every column with its type, length or precision, nullability, default, identity and collation; every primary key, unique constraint and index with its keys in order, sort direction, &lt;code&gt;INCLUDE&lt;/code&gt; columns, filter and clustering; every foreign key with its actions and whether SQL Server trusts it; every &lt;code&gt;CHECK&lt;/code&gt;. The two lists are diffed line by line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The behaviour probes.&lt;/strong&gt; A probe is a few statements in one transaction that is rolled back: insert 256 into a &lt;code&gt;tinyint&lt;/code&gt;, insert two rows with no order into a filtered unique column, delete a group and count its members. Its outcome is "accepted" or "refused at step N" with SQL Server's message number, and where it ends with a &lt;code&gt;SELECT&lt;/code&gt;, the value read back is part of the outcome. A foreign key a probe is not about is switched off with &lt;code&gt;NOCHECK&lt;/code&gt; inside the transaction, which the rollback undoes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;What it compares&lt;/th&gt;
&lt;th&gt;What it catches&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Catalog diff&lt;/td&gt;
&lt;td&gt;Columns, keys, indexes, foreign keys, as SQL Server's catalog reports them&lt;/td&gt;
&lt;td&gt;Anything missing, added or changed in the schema&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Probes, accept or refuse&lt;/td&gt;
&lt;td&gt;Which rows each database accepts&lt;/td&gt;
&lt;td&gt;A type range, unique index or foreign key that behaves differently&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Probes, read back&lt;/td&gt;
&lt;td&gt;What each database stored&lt;/td&gt;
&lt;td&gt;A value rounded or encoded differently&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 21 probes are 12 for Bitwarden and 9 for Smartstore. Smartstore's installer seeds rows in 35 tables and Bitwarden's migrator logs its scripts in one, which the copies do not have; every probe uses rows of its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  What failed before the fixes?
&lt;/h2&gt;

&lt;p&gt;Everything below is from Schemity's code just before these fixes (commit &lt;code&gt;a599d7a5&lt;/code&gt;), on both paths this time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three of the four scripts failed under sqlcmd.&lt;/strong&gt; Bitwarden's Import SQL script stopped at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Msg 8166, Level 16, State 1, Line 983
Constraint name '#Cols_pkey' not permitted. Constraint names cannot begin with a number sign (#).
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both live-path scripts stopped at the first filtered index with Msg 1934, &lt;code&gt;CREATE INDEX failed because the following SET options have incorrect settings: 'QUOTED_IDENTIFIER'&lt;/code&gt;. Smartstore's Import SQL script ran only because its filtered indexes had already lost their filters. With the phantom table removed by hand and the scripts run with &lt;code&gt;QUOTED_IDENTIFIER ON&lt;/code&gt;, as the app's own connection has it, all four copies could be compared.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Bitwarden, Import SQL&lt;/th&gt;
&lt;th&gt;Bitwarden, live&lt;/th&gt;
&lt;th&gt;Smartstore, Import SQL&lt;/th&gt;
&lt;th&gt;Smartstore, live&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Script ran&lt;/td&gt;
&lt;td&gt;no, Msg 8166&lt;/td&gt;
&lt;td&gt;no, Msg 1934&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;no, Msg 1934&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;varchar&lt;/code&gt; written as &lt;code&gt;nvarchar&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;75&lt;/td&gt;
&lt;td&gt;75&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tinyint&lt;/code&gt; / &lt;code&gt;money&lt;/code&gt; / &lt;code&gt;datetime&lt;/code&gt; widened&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;47 / 3 / 3&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0 / 0 / 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Filtered indexes changed or dropped&lt;/td&gt;
&lt;td&gt;22 of 22&lt;/td&gt;
&lt;td&gt;20 of 22&lt;/td&gt;
&lt;td&gt;5 of 5&lt;/td&gt;
&lt;td&gt;0 of 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Covering indexes without &lt;code&gt;INCLUDE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;19 of 19&lt;/td&gt;
&lt;td&gt;19 of 19&lt;/td&gt;
&lt;td&gt;4 of 4&lt;/td&gt;
&lt;td&gt;4 of 4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Indexes that lost a &lt;code&gt;DESC&lt;/code&gt; key&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clustered index made nonclustered&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Probes that match the original&lt;/td&gt;
&lt;td&gt;10 of 12&lt;/td&gt;
&lt;td&gt;7 of 12&lt;/td&gt;
&lt;td&gt;8 of 9&lt;/td&gt;
&lt;td&gt;8 of 9&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The probes that failed are the ones that matter to the data:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Probe&lt;/th&gt;
&lt;th&gt;Original&lt;/th&gt;
&lt;th&gt;Before the fixes&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bitwarden: &lt;code&gt;gw-Ā&lt;/code&gt; into a &lt;code&gt;varchar(50)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;stores &lt;code&gt;gw-A&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;stores &lt;code&gt;gw-Ā&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;stores &lt;code&gt;gw-A&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bitwarden: 256 into a &lt;code&gt;tinyint&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;refused, Msg 220&lt;/td&gt;
&lt;td&gt;accepted (live)&lt;/td&gt;
&lt;td&gt;refused&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bitwarden: an amount above &lt;code&gt;money&lt;/code&gt;'s range&lt;/td&gt;
&lt;td&gt;refused, Msg 8115&lt;/td&gt;
&lt;td&gt;accepted (live)&lt;/td&gt;
&lt;td&gt;refused&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bitwarden and Smartstore: &lt;code&gt;10:00:00.001&lt;/code&gt; into a &lt;code&gt;datetime&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;stores &lt;code&gt;.000&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;stores &lt;code&gt;.0010000&lt;/code&gt; (live)&lt;/td&gt;
&lt;td&gt;stores &lt;code&gt;.000&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bitwarden: two active tax rates for one postal code&lt;/td&gt;
&lt;td&gt;refused, Msg 2601&lt;/td&gt;
&lt;td&gt;accepted (Import SQL)&lt;/td&gt;
&lt;td&gt;refused&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bitwarden: two inactive tax rates for one postal code&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;td&gt;refused (live)&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Smartstore: two reward point records with no order&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;td&gt;refused (Import SQL)&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cascades on delete, the &lt;code&gt;CHECK&lt;/code&gt;, defaults, &lt;code&gt;IDENTITY&lt;/code&gt;, case-insensitive uniqueness and foreign keys all survived. Each failure had its own cause.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why did Import SQL read a procedure as a table?
&lt;/h3&gt;

&lt;p&gt;Because a script splits on more than &lt;code&gt;GO&lt;/code&gt;. A sqlpackage or SSMS script separates batches with &lt;code&gt;GO&lt;/code&gt;, and a procedure is one batch, but its body holds statements of its own, ended by semicolons. Bitwarden has 574 procedures, and one of them contains &lt;code&gt;CREATE TABLE #Cols (...)&lt;/code&gt;, a temporary table. Split at its semicolons, the body became separate statements, and that one was read as a table. Import SQL now keeps a &lt;code&gt;CREATE PROCEDURE&lt;/code&gt;, &lt;code&gt;FUNCTION&lt;/code&gt;, &lt;code&gt;TRIGGER&lt;/code&gt; or &lt;code&gt;VIEW&lt;/code&gt; batch whole and skips it, and names the 61 views it skipped. A diagram holds tables; a live connection reads the views as read-only tables.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4tt7xo6r7asgfqthuusr.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4tt7xo6r7asgfqthuusr.webp" alt="Schemity's Import SQL summary for Bitwarden's sqlpackage script on an empty diagram: the last rows of the table list, each marked New, then the note that 61 views are not imported, naming them, the line Skipped 643 statements that are not tables, such as views, sequences and procedures, and the button Import (64 new, 0 updated)" width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Why did the script fail under sqlcmd?
&lt;/h3&gt;

&lt;p&gt;Because a filtered index needs a session setting that sqlcmd leaves off. SQL Server refuses to create a filtered index unless &lt;code&gt;QUOTED_IDENTIFIER&lt;/code&gt; is &lt;code&gt;ON&lt;/code&gt;, and &lt;a href="https://learn.microsoft.com/en-us/sql/tools/sqlcmd/sqlcmd-utility" rel="noopener noreferrer"&gt;sqlcmd's own documentation&lt;/a&gt; says its default is &lt;code&gt;OFF&lt;/code&gt; unless you pass &lt;code&gt;-I&lt;/code&gt;. SSMS connects with it on and its Generate Scripts writes it before each table; the script sqlpackage extracted for this test carries no &lt;code&gt;SET&lt;/code&gt; at all, so it fails under sqlcmd the same way. Schemity's SQL Server scripts now start with &lt;code&gt;SET ANSI_NULLS ON&lt;/code&gt; and &lt;code&gt;SET QUOTED_IDENTIFIER ON&lt;/code&gt;, so View SQL's script runs the same in sqlcmd as in SSMS.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is varchar not nvarchar?
&lt;/h3&gt;

&lt;p&gt;Because they store different things. A &lt;code&gt;varchar&lt;/code&gt; holds one byte a character in the column's code page, here &lt;code&gt;SQL_Latin1_General_CP1_CI_AS&lt;/code&gt;; an &lt;code&gt;nvarchar&lt;/code&gt; holds Unicode at two bytes a character. The probe shows the difference: &lt;code&gt;Ā&lt;/code&gt; is not in that code page, so the original's &lt;code&gt;varchar(50)&lt;/code&gt; stored &lt;code&gt;gw-Ā&lt;/code&gt; as &lt;code&gt;gw-A&lt;/code&gt;, while the copy, with &lt;code&gt;nvarchar(50)&lt;/code&gt;, kept &lt;code&gt;gw-Ā&lt;/code&gt;. The copy also doubled the bytes of 75 columns, and an index on one of them counts the doubled size against SQL Server's limit on an index key.&lt;/p&gt;

&lt;p&gt;Schemity's SQL Server support used a reduced type list, and both paths folded a declared type into a wider one. The live reader read &lt;code&gt;varchar&lt;/code&gt; and &lt;code&gt;char&lt;/code&gt; as &lt;code&gt;nvarchar&lt;/code&gt; and &lt;code&gt;nchar&lt;/code&gt;, &lt;code&gt;tinyint&lt;/code&gt; as &lt;code&gt;smallint&lt;/code&gt;, &lt;code&gt;money&lt;/code&gt; as &lt;code&gt;decimal&lt;/code&gt;, and &lt;code&gt;datetime&lt;/code&gt; and &lt;code&gt;smalldatetime&lt;/code&gt; as &lt;code&gt;datetime2&lt;/code&gt;. Each fold changes a value or a range:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;tinyint&lt;/code&gt; holds 0 to 255, so the original refuses 256 with Msg 220; &lt;code&gt;smallint&lt;/code&gt; takes it.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://learn.microsoft.com/en-us/sql/t-sql/data-types/money-and-smallmoney-transact-sql" rel="noopener noreferrer"&gt;money&lt;/a&gt; stops at 922,337,203,685,477.5807; &lt;code&gt;decimal(19,4)&lt;/code&gt; goes higher, so the copy accepted an amount the original refuses with Msg 8115.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://learn.microsoft.com/en-us/sql/t-sql/data-types/datetime-transact-sql" rel="noopener noreferrer"&gt;datetime&lt;/a&gt; rounds to increments of &lt;code&gt;.000&lt;/code&gt;, &lt;code&gt;.003&lt;/code&gt; or &lt;code&gt;.007&lt;/code&gt; seconds, so &lt;code&gt;10:00:00.001&lt;/code&gt; is stored as &lt;code&gt;10:00:00.000&lt;/code&gt;; &lt;code&gt;datetime2(7)&lt;/code&gt; keeps it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The diagram now keeps each type as SQL Server declares it on both paths, and the &lt;a href="https://schemity.com/doc/tables-and-fields/" rel="noopener noreferrer"&gt;field editor&lt;/a&gt; offers SQL Server's own types: &lt;code&gt;varchar&lt;/code&gt;, &lt;code&gt;char&lt;/code&gt;, &lt;code&gt;varchar(max)&lt;/code&gt;, &lt;code&gt;tinyint&lt;/code&gt;, &lt;code&gt;money&lt;/code&gt;, &lt;code&gt;smallmoney&lt;/code&gt;, &lt;code&gt;datetime&lt;/code&gt;, &lt;code&gt;smalldatetime&lt;/code&gt;, &lt;code&gt;real&lt;/code&gt;, &lt;code&gt;binary&lt;/code&gt;, &lt;code&gt;varbinary&lt;/code&gt; and &lt;code&gt;xml&lt;/code&gt; beside the ones it had. A diagram drawn for another database still converts its text columns to &lt;code&gt;nvarchar&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fixkni86ko9h7hxjptldr.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fixkni86ko9h7hxjptldr.webp" alt="The Billing legend of the Bitwarden diagram read from the live database: OrganizationPlanMigrationCohort.MigrationPathId as TINYINT, Transaction.Amount as MONEY, TaxRate's columns as VARCHAR(40), VARCHAR(50), VARCHAR(10) and VARCHAR(2), and SubscriptionDiscount's as VARCHAR(10) and VARCHAR(20), with the minimap of all 11 legends in the corner; the status bar reads Entities: 125, Relations: 88 (+30 virtual hidden)" width="800" height="528"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens to a filtered unique index?
&lt;/h3&gt;

&lt;p&gt;It depends on its filter, and before the fixes both readings were wrong in a different way. On SQL Server a unique index treats &lt;code&gt;NULL&lt;/code&gt; as a value, so it allows one row without a value. A unique index filtered to &lt;code&gt;WHERE col IS NOT NULL&lt;/code&gt; is how SQL Server writes the standard rule, many &lt;code&gt;NULL&lt;/code&gt;s allowed, and Smartstore has five of them, among them &lt;code&gt;IX_RewardPointsHistory_UsedWithOrder_Id&lt;/code&gt;. Import SQL dropped the filter, so the copy refused the second reward point record with no order. It now reads such an index as a unique rule that ignores &lt;code&gt;NULL&lt;/code&gt;, as the live reader already did, and writes it back as the same filtered index.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0d7ri62bvegcrwr1ymei.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0d7ri62bvegcrwr1ymei.webp" alt="Schemity's Entity SQL dialog for Smartstore's RewardPointsHistory, in the diagram imported from its script: 16 numbered lines starting SET ANSI_NULLS ON and SET QUOTED_IDENTIFIER ON, the table with its two foreign keys, and CREATE UNIQUE INDEX IX_RewardPointsHistory_UsedWithOrder_Id ON RewardPointsHistory (UsedWithOrder_Id) WHERE UsedWithOrder_Id IS NOT NULL" width="799" height="477"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Bitwarden's &lt;code&gt;IX_TaxRate_Country_PostalCode_Active_Uniqueness&lt;/code&gt; is the other kind: unique &lt;code&gt;WHERE ([Active]=(1))&lt;/code&gt;, one active rate per postal code. Import SQL left it out entirely, so the copy accepted two active rates; the live reader dropped its filter, so the copy refused two inactive ones. The diagram now keeps a SQL Server index's filter as its &lt;code&gt;WHERE&lt;/code&gt;, as it keeps a PostgreSQL partial index's.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjntq6ya1teuyv8fztd8t.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fjntq6ya1teuyv8fztd8t.webp" alt="Schemity's index dialog for Bitwarden's IX_TaxRate_Country_PostalCode_Active_Uniqueness: Fields Country and PostalCode, Where ([Active]=(1)), Type NONCLUSTERED and Unique ticked, over TaxRate's index list showing the same WHERE and a tick in the U column" width="800" height="483"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What about INCLUDE, DESC and clustered indexes?
&lt;/h3&gt;

&lt;p&gt;They change speed, not rows, and they were all lost. A covering index's &lt;code&gt;INCLUDE&lt;/code&gt; columns, a key sorted &lt;code&gt;DESC&lt;/code&gt; and a &lt;code&gt;CLUSTERED&lt;/code&gt; index that is not the primary key are now kept on both paths and written back; a table whose clustered index is another index gets its primary key written &lt;code&gt;NONCLUSTERED&lt;/code&gt;, since SQL Server allows one clustered index a table. Neither schema needed that last one: Smartstore's clustered unique index is on a table with no primary key. &lt;code&gt;INCLUDE&lt;/code&gt; turned up a PostgreSQL bug on the way: its live reader read a covering index's &lt;code&gt;INCLUDE&lt;/code&gt; columns as key columns. PostgreSQL indexes keep &lt;code&gt;INCLUDE&lt;/code&gt; now too.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh26vqfze3nbl173o9fvj.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh26vqfze3nbl173o9fvj.webp" alt="Schemity's index dialog for Bitwarden's IX_Event_DateOrganizationIdUserId: Keys [Date] DESC, [OrganizationId], [ActingUserId], [CipherId], Include ServiceAccountId, GrantedServiceAccountId, Type NONCLUSTERED; behind it, Event's index list shows IX_Event_OrganizationIdSendIdDate with [Date] DESC WHERE ([SendId] IS NOT NULL)" width="800" height="483"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Which bugs did only a refresh find?
&lt;/h3&gt;

&lt;p&gt;Import a script, connect to the database it came from, and &lt;a href="https://schemity.com/doc/resync-database/" rel="noopener noreferrer"&gt;Refresh &amp;amp; merge&lt;/a&gt; should find nothing to change. Measured through the app's own merge code, called from a script, it planned 113 statements for Bitwarden and 459 for Smartstore, all spelling: &lt;code&gt;INTEGER&lt;/code&gt; from the script against &lt;code&gt;INT&lt;/code&gt; from the reader, a &lt;code&gt;CHECK&lt;/code&gt; that SQL Server stores in its own lowercase form, &lt;code&gt;INCLUDE&lt;/code&gt; columns in another order, and names in brackets against names without. Both refreshes now list nothing, and so do the four MySQL and PostgreSQL schemas through the same check.&lt;/p&gt;

&lt;p&gt;The same holds in the app's window the other way round: Import SQL of Bitwarden's script onto the diagram read from its live database finds nothing to change.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frajkguhs1wy5gglv51v4.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frajkguhs1wy5gglv51v4.webp" alt="Schemity's Import SQL summary for Bitwarden's script on the diagram read from the live database: the last rows of the table list, each marked Unchanged, the same 61 views not imported and 643 statements skipped, and the button Import (0 new, 0 updated) disabled" width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two more turned up while this post was being checked, in schemas neither app has. A script's &lt;code&gt;INT&lt;/code&gt; primary key without &lt;code&gt;IDENTITY&lt;/code&gt;, the shape of a lookup table with fixed ids, came back as &lt;code&gt;IDENTITY(1,1)&lt;/code&gt;, so the copy refused the ids the application inserts; the diagram now records whether SQL Server numbers a column, on both paths. And Import SQL wrote a deprecated &lt;code&gt;text&lt;/code&gt; column as &lt;code&gt;nvarchar(max)&lt;/code&gt;, the same widening as above; it now reads &lt;code&gt;text&lt;/code&gt;, &lt;code&gt;ntext&lt;/code&gt; and &lt;code&gt;image&lt;/code&gt; as &lt;code&gt;varchar(max)&lt;/code&gt;, &lt;code&gt;nvarchar(max)&lt;/code&gt; and &lt;code&gt;varbinary(max)&lt;/code&gt;, as the live reader does.&lt;/p&gt;

&lt;p&gt;One more turned up later, while taking the SQLite post's screenshots, which the scripts here could not see because they skip a step the app takes when it reads a table: the app lists a table's fields key first, foreign keys next and timestamps last, and wrote &lt;code&gt;CREATE TABLE&lt;/code&gt; in that order, so a copy built from the diagram met a &lt;code&gt;SELECT *&lt;/code&gt; or an &lt;code&gt;INSERT&lt;/code&gt; without a column list with its columns in another order. The diagram now remembers each column's position as the table declares it and writes that order, while the canvas keeps its own; a column added in the diagram goes last, where &lt;code&gt;ADD COLUMN&lt;/code&gt; puts it. Read and written back the way the app does it, 62 of Bitwarden's 64 tables and 96 of Smartstore's 118 came back in a different column order before this fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the round trip give you now?
&lt;/h2&gt;

&lt;p&gt;The same database, on both paths, for both schemas.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Import SQL&lt;/th&gt;
&lt;th&gt;Live connection&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Bitwarden: catalog differences&lt;/td&gt;
&lt;td&gt;2 foreign keys &lt;code&gt;WITH NOCHECK&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;the same&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bitwarden: probes that match the original&lt;/td&gt;
&lt;td&gt;12 of 12&lt;/td&gt;
&lt;td&gt;12 of 12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Smartstore: catalog differences&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Smartstore: probes that match the original&lt;/td&gt;
&lt;td&gt;9 of 9&lt;/td&gt;
&lt;td&gt;9 of 9&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every one of the 75 &lt;code&gt;varchar&lt;/code&gt;, 47 &lt;code&gt;tinyint&lt;/code&gt;, 3 &lt;code&gt;money&lt;/code&gt; and 4 &lt;code&gt;datetime&lt;/code&gt; columns, the 27 filtered indexes, 23 covering indexes, 6 indexes with a &lt;code&gt;DESC&lt;/code&gt; key and Smartstore's clustered unique index come back as they were, and the scripts run under sqlcmd without &lt;code&gt;-I&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What still differs, and why?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Two foreign keys SQL Server does not trust.&lt;/strong&gt; Bitwarden's &lt;code&gt;FK_EmergencyAccess_GranteeId&lt;/code&gt; and &lt;code&gt;FK_EmergencyAccess_GrantorId&lt;/code&gt; were created &lt;code&gt;WITH NOCHECK&lt;/code&gt;, so SQL Server did not check the rows already in the table when they were added and marks them not trusted. Both copies create them as ordinary, trusted keys. Every new row is checked either way, and an empty copy has no old rows to skip, so the diagram does not record it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Objects a diagram does not hold.&lt;/strong&gt; Bitwarden's 574 procedures, 3 functions and 5 table types are skipped by Import SQL, and its 61 views are skipped by name; a live connection reads the views as read-only tables. None of them is written back. SQL Server's deprecated &lt;code&gt;text&lt;/code&gt;, &lt;code&gt;ntext&lt;/code&gt; and &lt;code&gt;image&lt;/code&gt; types are written as &lt;code&gt;varchar(max)&lt;/code&gt;, &lt;code&gt;nvarchar(max)&lt;/code&gt; and &lt;code&gt;varbinary(max)&lt;/code&gt; on both paths; neither schema uses them. An &lt;code&gt;IDENTITY&lt;/code&gt; column comes back with seed 1 and increment 1, which every identity in both schemas has.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this benchmark does not show
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The app's window.&lt;/strong&gt; These runs called Schemity's import, reader, export and comparison code from scripts on a development build. The screenshots are from the app's window on the same build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data.&lt;/strong&gt; The originals hold only their seed rows. A schema that matches is the precondition for moving data, not proof that a migration of a full database is safe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Triggers.&lt;/strong&gt; Neither schema has one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Azure SQL and older servers.&lt;/strong&gt; Everything ran on SQL Server 2022 CU26.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Other databases.&lt;/strong&gt; SQLite has &lt;a href="https://schemity.com/blog/sqlite-round-trip-campfire-jellyfin/" rel="noopener noreferrer"&gt;its own post&lt;/a&gt;, with Campfire and Jellyfin, and so has Snowflake, &lt;a href="https://schemity.com/blog/snowflake-round-trip-tpcds/" rel="noopener noreferrer"&gt;with TPC-DS&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schemas this size are not a stress test.&lt;/strong&gt; 64 and 118 tables are typical; a thousand-table schema is covered in &lt;a href="https://schemity.com/blog/database-diagram-1000-tables-memory-cpu/" rel="noopener noreferrer"&gt;a separate post&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to rerun it
&lt;/h2&gt;

&lt;p&gt;Create each schema with its own tooling against an empty SQL Server, take &lt;code&gt;sqlpackage /a:Extract /SourceConnectionString:&amp;lt;connection&amp;gt; /TargetFile:&amp;lt;schema&amp;gt;.sql /p:ExtractTarget=File&lt;/code&gt;, put the script or the database through Schemity, export the SQL and apply it to an empty database. Then list both catalogs with a query like this one, a shorter form of the benchmark's, run with &lt;code&gt;sqlcmd -d &amp;lt;database&amp;gt; -h -1 -y 8000&lt;/code&gt;, and diff the output:&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;NOCOUNT&lt;/span&gt; &lt;span class="k"&gt;ON&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;cols&lt;/span&gt; &lt;span class="k"&gt;AS&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;CONCAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'column | '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;' | '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;' | '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ty&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="k"&gt;CASE&lt;/span&gt;
        &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;ty&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'nvarchar'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'nchar'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;IIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="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="s1"&gt;'(max)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CONCAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'('&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;')'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;ty&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'varchar'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'char'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'varbinary'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'binary'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;IIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="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="s1"&gt;'(max)'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CONCAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'('&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;')'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;ty&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'decimal'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'numeric'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;CONCAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'('&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;precision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;','&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;')'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;ty&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'datetime2'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'datetimeoffset'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'time'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;CONCAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'('&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;')'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;IIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_nullable&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="s1"&gt;' | null'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;' | not null'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="s1"&gt;' | '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;ISNULL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'default '&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;dc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;definition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'no default'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="s1"&gt;' | '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;column_id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'-'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CONCAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'identity('&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;CONVERT&lt;/span&gt;&lt;span class="p"&gt;(&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;40&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;ic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seed_value&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;','&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;CONVERT&lt;/span&gt;&lt;span class="p"&gt;(&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;40&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;ic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;increment_value&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;')'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
      &lt;span class="s1"&gt;' | '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;ISNULL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;collation_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'-'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tables&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;
  &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt;
  &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;types&lt;/span&gt; &lt;span class="n"&gt;ty&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;ty&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_type_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_type_id&lt;/span&gt;
  &lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;default_constraints&lt;/span&gt; &lt;span class="n"&gt;dc&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;dc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent_object_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;dc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent_column_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;column_id&lt;/span&gt;
  &lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;identity_columns&lt;/span&gt; &lt;span class="n"&gt;ic&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;ic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;ic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;column_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;column_id&lt;/span&gt;
&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;idx&lt;/span&gt; &lt;span class="k"&gt;AS&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;CONCAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_primary_key&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="s1"&gt;'pk'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_unique_constraint&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="s1"&gt;'unique constraint'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'index'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
      &lt;span class="s1"&gt;' | '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;' | '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;' | '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_unique&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="s1"&gt;'unique'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'non-unique'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;' | '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;LOWER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;type_desc&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;' | '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;STRING_AGG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CONCAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_descending_key&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="s1"&gt;' desc'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="s1"&gt;', '&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;WITHIN&lt;/span&gt; &lt;span class="k"&gt;GROUP&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;ic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key_ordinal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
         &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;index_columns&lt;/span&gt; &lt;span class="n"&gt;ic&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;column_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;column_id&lt;/span&gt;
         &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;ic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;index_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;index_id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;ic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_included_column&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;ISNULL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;' | include '&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;STRING_AGG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;', '&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;WITHIN&lt;/span&gt; &lt;span class="k"&gt;GROUP&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="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
         &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;index_columns&lt;/span&gt; &lt;span class="n"&gt;ic&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;column_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;column_id&lt;/span&gt;
         &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;ic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;index_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;index_id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;ic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_included_column&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="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="k"&gt;ISNULL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;' | where '&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;filter_definition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;indexes&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tables&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="o"&gt;&amp;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;fks&lt;/span&gt; &lt;span class="k"&gt;AS&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;CONCAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'fk | '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;' | '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&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;SELECT&lt;/span&gt; &lt;span class="n"&gt;STRING_AGG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;', '&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;WITHIN&lt;/span&gt; &lt;span class="k"&gt;GROUP&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;fc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;constraint_column_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
         &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;foreign_key_columns&lt;/span&gt; &lt;span class="n"&gt;fc&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent_object_id&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;column_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent_column_id&lt;/span&gt;
         &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;fc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;constraint_object_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="s1"&gt;' | '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OBJECT_NAME&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;referenced_object_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="s1"&gt;' | on delete '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;LOWER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delete_referential_action_desc&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;' | on update '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;LOWER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;update_referential_action_desc&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="n"&gt;IIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_not_trusted&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="s1"&gt;' | not trusted'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;foreign_keys&lt;/span&gt; &lt;span class="n"&gt;fk&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tables&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fk&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent_object_id&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;line&lt;/span&gt; &lt;span class="k"&gt;FROM&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;line&lt;/span&gt; &lt;span class="k"&gt;COLLATE&lt;/span&gt; &lt;span class="n"&gt;Latin1_General_BIN2&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;cols&lt;/span&gt;
  &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="k"&gt;COLLATE&lt;/span&gt; &lt;span class="n"&gt;Latin1_General_BIN2&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;idx&lt;/span&gt;
  &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="k"&gt;COLLATE&lt;/span&gt; &lt;span class="n"&gt;Latin1_General_BIN2&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;fks&lt;/span&gt;
  &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;CONCAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'check | '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;' | '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ck&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;' | '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ck&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;definition&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;COLLATE&lt;/span&gt; &lt;span class="n"&gt;Latin1_General_BIN2&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;check_constraints&lt;/span&gt; &lt;span class="n"&gt;ck&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tables&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ck&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent_object_id&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;catalog&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;line&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For behaviour, run a few statements per rule inside &lt;code&gt;BEGIN TRANSACTION&lt;/code&gt; and &lt;code&gt;ROLLBACK&lt;/code&gt; on the original and on the copy, end each with a &lt;code&gt;SELECT&lt;/code&gt; of what was stored, and compare both the step SQL Server refuses at and the values it reads back. Connect with &lt;code&gt;QUOTED_IDENTIFIER ON&lt;/code&gt;, or SQL Server refuses every write to a table with a filtered index before your probe gets to its point.&lt;/p&gt;

</description>
      <category>sqlserver</category>
      <category>database</category>
      <category>dotnet</category>
      <category>efcore</category>
    </item>
    <item>
      <title>Round-Tripping Invoice Ninja and Sylius Through a Database Diagram: What MySQL Lost and What We Fixed</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Tue, 20 Oct 2026 14:52:02 +0000</pubDate>
      <link>https://dev.to/tbson87/round-tripping-invoice-ninja-and-sylius-through-a-database-diagram-what-mysql-lost-and-what-we-m71</link>
      <guid>https://dev.to/tbson87/round-tripping-invoice-ninja-and-sylius-through-a-database-diagram-what-mysql-lost-and-what-we-m71</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://schemity.com" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt;, a desktop ERD tool - this post is from our blog and uses it for the examples.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; We put two real MySQL schemas, Invoice Ninja (Laravel) and Sylius (Symfony with Doctrine), through Schemity and rebuilt a database from the SQL it wrote. Before this round of fixes MySQL refused Invoice Ninja's script, and once patched the copies built from the dumps dropped microseconds, stopped rounding a &lt;code&gt;double(8,2)&lt;/code&gt; and accepted duplicate keys the originals refuse. After the fixes Invoice Ninja comes back with no difference at all, Sylius differs only in 16 column comments Doctrine no longer uses, and both behave the same in 19 probes.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A database diagram that reads a MySQL schema and writes it back should give you the same database, and before this round of fixes Schemity did not. We took two real open-source schemas, Invoice Ninja v5.13.47 (Laravel, 80 tables) and Sylius v2.3.0 (Symfony with Doctrine, 115 tables), put each through Schemity from its &lt;code&gt;mysqldump&lt;/code&gt; and from the live database, and rebuilt a database from the SQL Schemity wrote. MySQL refused Invoice Ninja's script outright, and once it was patched to run, the copies built from both dumps stored different values and accepted rows the originals refuse. After the fixes, Invoice Ninja comes back with no catalog difference at all on either path, Sylius differs only in 16 column comments, and both behave exactly like their originals in 19 behaviour probes.&lt;/p&gt;

&lt;p&gt;Schemity is database design software that reads your live database, shows the impact of every schema change before it runs, and keeps the diagram as a file in Git. This is the second post in a series that tests the round trip on each database it supports; the first, &lt;a href="https://schemity.com/blog/postgres-round-trip-mastodon-saleor/" rel="noopener noreferrer"&gt;round-tripping Mastodon and Saleor&lt;/a&gt;, did the same for PostgreSQL. Reading a schema you did not write is how you understand your schema before changing it, and MySQL turned out to lose different things from PostgreSQL: not keys and indexes, but the column details that decide what a value is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which two schemas, and which two paths?
&lt;/h2&gt;

&lt;p&gt;Two schemas created by their own frameworks, chosen because both declare real foreign keys. Not every MySQL application does: WordPress's own &lt;code&gt;CREATE TABLE&lt;/code&gt; statements have none, and a round trip of a schema without them would prove little.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Invoice Ninja v5.13.47&lt;/th&gt;
&lt;th&gt;Sylius v2.3.0&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What it is&lt;/td&gt;
&lt;td&gt;Invoicing and billing&lt;/td&gt;
&lt;td&gt;E-commerce platform&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Created by&lt;/td&gt;
&lt;td&gt;Laravel, &lt;code&gt;php artisan migrate&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Symfony with Doctrine, &lt;code&gt;doctrine:migrations:migrate&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tables&lt;/td&gt;
&lt;td&gt;80&lt;/td&gt;
&lt;td&gt;115&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Columns&lt;/td&gt;
&lt;td&gt;1,727&lt;/td&gt;
&lt;td&gt;738&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Foreign keys&lt;/td&gt;
&lt;td&gt;138: 127 &lt;code&gt;ON DELETE CASCADE&lt;/code&gt;, 114 &lt;code&gt;ON UPDATE CASCADE&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;141: 71 &lt;code&gt;ON DELETE CASCADE&lt;/code&gt;, 10 &lt;code&gt;ON DELETE SET NULL&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unique keys&lt;/td&gt;
&lt;td&gt;34&lt;/td&gt;
&lt;td&gt;78&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;UNSIGNED&lt;/code&gt; columns&lt;/td&gt;
&lt;td&gt;437&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;timestamp(6)&lt;/code&gt; columns&lt;/td&gt;
&lt;td&gt;141&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tinyint(1)&lt;/code&gt; flags&lt;/td&gt;
&lt;td&gt;224&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Indexes on a column prefix&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ENUM&lt;/code&gt; / &lt;code&gt;JSON&lt;/code&gt; columns&lt;/td&gt;
&lt;td&gt;13 / 2&lt;/td&gt;
&lt;td&gt;0 / 15&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Collation&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;utf8mb4_unicode_ci&lt;/code&gt; throughout&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;utf8mb4_unicode_ci&lt;/code&gt;, one table &lt;code&gt;utf8mb4_0900_ai_ci&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Column comments&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Views, triggers, routines, &lt;code&gt;CHECK&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each schema was created by its framework in an empty MySQL 8.4.11 database, Invoice Ninja inside the official &lt;code&gt;invoiceninja/invoiceninja-debian:5.13.47&lt;/code&gt; image and Sylius from &lt;code&gt;sylius/sylius-standard&lt;/code&gt; v2.3.0, and then went through Schemity on two paths:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Import SQL:&lt;/strong&gt; the &lt;code&gt;mysqldump --no-data&lt;/code&gt; file read by Schemity's SQL parser, with no connection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live connection:&lt;/strong&gt; Schemity's MySQL reader connected to the original database and read &lt;code&gt;information_schema&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both paths end the same way: the diagram's SQL is applied to an empty database on the same server. The runs used Schemity's own code from a development build, called from scripts rather than through the app's window, and the live path always read the original database, never a copy Schemity had built. The fixes described below are in that build and ship in the next release; until then, the released app behaves like the "before" column.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdxm0v7nqeb8v18bpfm6s.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdxm0v7nqeb8v18bpfm6s.webp" alt="Schemity's Import SQL summary for Invoice Ninja's mysqldump on an empty diagram: the last rows of the table list, from subscriptions to webhooks, each marked New with its column count, and the button Import (80 new, 0 updated)" width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1veeyla6yv7efx9lxsy1.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1veeyla6yv7efx9lxsy1.webp" alt="Part of the Invoice Ninja diagram read from the live database at full size, with the Activity, Documents &amp;amp; Tags legend in view, the Accounts &amp;amp; Users legend beside it and the minimap in the bottom right corner; the status bar reads Entities: 80, Relations: 138 (+132 virtual hidden), MySQL: 8.4.6" width="799" height="525"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you prove a rebuilt MySQL schema is the same?
&lt;/h2&gt;

&lt;p&gt;Two checks, as in the PostgreSQL post, with one addition MySQL needs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The catalog diff.&lt;/strong&gt; A query over &lt;code&gt;information_schema&lt;/code&gt; lists one line per fact for the original and for the copy: every table with its engine and collation, every column with its full &lt;code&gt;column_type&lt;/code&gt;, nullability, default, extra (&lt;code&gt;auto_increment&lt;/code&gt;, &lt;code&gt;on update&lt;/code&gt;), collation and comment, every index with its prefix lengths, and every foreign key with its actions. The two lists are diffed line by line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The behaviour probes.&lt;/strong&gt; A probe is a few statements in one transaction that is rolled back: insert a row with a microsecond timestamp, insert two company keys that differ only by a trailing space, delete an account and count its companies. Its outcome is "accepted" or "refused at step N" with MySQL's error code, and a copy passes when its outcome is the original's.&lt;/p&gt;

&lt;p&gt;The addition is reading back. On MySQL a column that lost a detail usually does not refuse anything: it accepts the row and stores something else. So a probe can end with a &lt;code&gt;SELECT&lt;/code&gt;, and the value it reads is part of the outcome.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;What it compares&lt;/th&gt;
&lt;th&gt;What it catches&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Catalog diff&lt;/td&gt;
&lt;td&gt;Tables, columns, indexes and foreign keys, as &lt;code&gt;information_schema&lt;/code&gt; reports them&lt;/td&gt;
&lt;td&gt;Anything missing, added or changed in the schema&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Probes, accept or refuse&lt;/td&gt;
&lt;td&gt;Which rows each database accepts&lt;/td&gt;
&lt;td&gt;A unique key or foreign key that behaves differently&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Probes, read back&lt;/td&gt;
&lt;td&gt;What each database stored&lt;/td&gt;
&lt;td&gt;A value rounded or defaulted differently&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 19 probes are 11 for Invoice Ninja and 8 for Sylius, run under MySQL 8.4's default strict &lt;code&gt;sql_mode&lt;/code&gt;, which is what makes an out-of-range or too-long value an error rather than a warning. Invoice Ninja's migrations seed a few reference tables, 21 currencies among them, which the copies do not have; every probe uses rows and ids of its own, so no seed row decides an outcome.&lt;/p&gt;

&lt;h2&gt;
  
  
  What failed before the fixes?
&lt;/h2&gt;

&lt;p&gt;Everything below is from Schemity as of 15 October 2026, through Import SQL, the same scope as the PostgreSQL post's "before" column. The live path was already better: Schemity's MySQL reader kept every collation, and Sylius came back from it differing only in its comments. It refused Invoice Ninja's script at the same index.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MySQL refused Invoice Ninja's script.&lt;/strong&gt; It stopped at line 492 with:&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="n"&gt;ERROR&lt;/span&gt; &lt;span class="mi"&gt;1170&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42000&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;BLOB&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;column&lt;/span&gt; &lt;span class="s1"&gt;'client_hash'&lt;/span&gt; &lt;span class="n"&gt;used&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="n"&gt;specification&lt;/span&gt; &lt;span class="k"&gt;without&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="k"&gt;length&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the three prefix indexes put back by hand, the script ran, and both copies could be compared with their originals.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Invoice Ninja&lt;/th&gt;
&lt;th&gt;Sylius&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Script ran&lt;/td&gt;
&lt;td&gt;no, error 1170, ran once patched&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Columns that lost their collation&lt;/td&gt;
&lt;td&gt;593&lt;/td&gt;
&lt;td&gt;258&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tables that lost their collation&lt;/td&gt;
&lt;td&gt;all 80&lt;/td&gt;
&lt;td&gt;114 of 115&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;timestamp(6)&lt;/code&gt; written as &lt;code&gt;timestamp&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;141&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;double(8,2)&lt;/code&gt; written as &lt;code&gt;double&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Column comments lost&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Probes that match the original&lt;/td&gt;
&lt;td&gt;8 of 11&lt;/td&gt;
&lt;td&gt;7 of 8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The four probes that failed are the four that matter to the data:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Probe&lt;/th&gt;
&lt;th&gt;Original&lt;/th&gt;
&lt;th&gt;Before the fixes&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Invoice Ninja: a timestamp set to &lt;code&gt;10:00:00.123456&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;stores &lt;code&gt;.123456&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;stores &lt;code&gt;10:00:00&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;stores &lt;code&gt;.123456&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invoice Ninja: a discount of &lt;code&gt;1.239&lt;/code&gt; in a &lt;code&gt;double(8,2)&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;stores &lt;code&gt;1.24&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;stores &lt;code&gt;1.239&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;stores &lt;code&gt;1.24&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invoice Ninja: two company keys differing only by a trailing space&lt;/td&gt;
&lt;td&gt;refused, duplicate&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;td&gt;refused&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sylius: two customer emails differing only by a trailing space&lt;/td&gt;
&lt;td&gt;refused, duplicate&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;td&gt;refused&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every other probe passed even before the fixes: cascades on delete and update, &lt;code&gt;SET NULL&lt;/code&gt;, foreign keys, &lt;code&gt;UNSIGNED&lt;/code&gt;, &lt;code&gt;ENUM&lt;/code&gt;, &lt;code&gt;JSON&lt;/code&gt; validity and column lengths all survived. Each failure had its own cause.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why did MySQL refuse an index on a TEXT column?
&lt;/h3&gt;

&lt;p&gt;Because MySQL can only index a &lt;code&gt;TEXT&lt;/code&gt; column by a prefix of it. Invoice Ninja declares &lt;code&gt;KEY (client_hash(20))&lt;/code&gt; on a &lt;code&gt;TEXT&lt;/code&gt; column, and two more on &lt;code&gt;contact_key&lt;/code&gt;. The diagram held an index as a list of column names, so the 20 was dropped on both paths and the script said &lt;code&gt;CREATE INDEX ... (client_hash)&lt;/code&gt;, which MySQL refuses. The diagram now keeps a column prefix as the index's key list, &lt;code&gt;`client_hash`(20)&lt;/code&gt;, &lt;a href="https://schemity.com/doc/export-sql-ddl/" rel="noopener noreferrer"&gt;written back&lt;/a&gt; as it was, and the table's index list shows it. Converting such a diagram to another database writes a non-unique prefix index as a plain index on the whole column, and leaves a unique one out, named in the script's notes, because uniqueness on the first 20 characters is a different rule from uniqueness on all of them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi6jed9tzuk6l492xxsyg.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi6jed9tzuk6l492xxsyg.webp" alt="Schemity's index dialog for clients_client_hash(20)_index on Invoice Ninja's clients table, with Keys set to client_hash(20) and Type BTREE, over the table's index list, where the same index shows the key client_hash(20)" width="799" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Why did keys that differ by a trailing space stop being duplicates?
&lt;/h3&gt;

&lt;p&gt;Because the copies lost their collation, and MySQL 8's default compares trailing spaces differently. Every text column in Invoice Ninja, and all but one in Sylius, is &lt;code&gt;utf8mb4_unicode_ci&lt;/code&gt;, which has the &lt;code&gt;PAD SPACE&lt;/code&gt; attribute: trailing spaces are ignored, so &lt;code&gt;'probe-key'&lt;/code&gt; and &lt;code&gt;'probe-key '&lt;/code&gt; are the same value and a unique key refuses the second. Import SQL read no character set or collation from a dump, so the copies took the server default, &lt;code&gt;utf8mb4_0900_ai_ci&lt;/code&gt;, which is &lt;code&gt;NO PAD&lt;/code&gt;: the two strings differ, and the duplicate goes in. &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/charset-binary-collations.html" rel="noopener noreferrer"&gt;MySQL's documentation on the pad attribute&lt;/a&gt; has the rule.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;mysqldump&lt;/code&gt; makes this easy to miss, because it spells a collation four ways. A table's is &lt;code&gt;DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci&lt;/code&gt;. A column that takes its table's gets a bare &lt;code&gt;COLLATE utf8mb4_unicode_ci&lt;/code&gt;, with no character set, which is how all 593 of Invoice Ninja's text columns appear. A column set on its own gets &lt;code&gt;CHARACTER SET utf8mb4 COLLATE ...&lt;/code&gt;. And a column whose collation is its character set's default, as on Sylius's one &lt;code&gt;utf8mb4_0900_ai_ci&lt;/code&gt; table, gets nothing. &lt;a href="https://schemity.com/doc/reverse-engineer-database/" rel="noopener noreferrer"&gt;Import SQL&lt;/a&gt; now reads all four, takes the character set from the collation's name, and gives a column with no clause its table's, which is how the live reader reports the same columns. The same mismatch is why a foreign key between two &lt;code&gt;CHAR(36)&lt;/code&gt; columns can fail with error 3780, covered in &lt;a href="https://schemity.com/blog/mysql-unsigned-vs-signed-primary-key/" rel="noopener noreferrer"&gt;signed vs unsigned MySQL keys&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4w9wjgq4wuclm1jsmi29.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4w9wjgq4wuclm1jsmi29.webp" alt="Schemity's field dialog for companies.company_key in the Invoice Ninja diagram imported from its dump: a VARCHAR of length 100, Unique, with Charset utf8mb4 and Collation utf8mb4_unicode_ci" width="799" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Why did timestamp(6) lose its microseconds?
&lt;/h3&gt;

&lt;p&gt;Because a diagram field had no place for fractional seconds. A MySQL &lt;code&gt;TIMESTAMP&lt;/code&gt;, &lt;code&gt;DATETIME&lt;/code&gt; or &lt;code&gt;TIME&lt;/code&gt; with no precision keeps whole seconds, and &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/fractional-seconds.html" rel="noopener noreferrer"&gt;MySQL rounds a fraction away without a warning&lt;/a&gt;. Invoice Ninja's 141 &lt;code&gt;timestamp(6)&lt;/code&gt; columns came back as &lt;code&gt;timestamp&lt;/code&gt;, so every &lt;code&gt;created_at&lt;/code&gt; and &lt;code&gt;updated_at&lt;/code&gt; (51 of each) and &lt;code&gt;deleted_at&lt;/code&gt; (39) was rounded to the second: two rows written in the same second sort as equal, and a value compared with the application's own microsecond copy no longer matches.&lt;/p&gt;

&lt;p&gt;The live reader knew the 6 but used it only when it restated an existing column, never in a &lt;code&gt;CREATE TABLE&lt;/code&gt;. A time column's precision now lives in the field's precision, the slot &lt;code&gt;DECIMAL&lt;/code&gt; already used, on every database that has one: MySQL's &lt;code&gt;TIMESTAMP(6)&lt;/code&gt;, SQL Server's &lt;code&gt;DATETIME2(3)&lt;/code&gt;, Snowflake's &lt;code&gt;TIMESTAMP_NTZ(9)&lt;/code&gt;. The &lt;a href="https://schemity.com/doc/tables-and-fields/" rel="noopener noreferrer"&gt;field editor&lt;/a&gt; offers it up to each database's maximum. Writing &lt;code&gt;TIMESTAMP(6)&lt;/code&gt; also needs the clock to match: MySQL refuses &lt;code&gt;DATETIME(6) DEFAULT CURRENT_TIMESTAMP&lt;/code&gt; with error 1067, so the default and &lt;code&gt;ON UPDATE&lt;/code&gt; are written as &lt;code&gt;CURRENT_TIMESTAMP(6)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Checking this post turned up one more loss in the same columns, in a shape neither schema has: &lt;code&gt;ON UPDATE CURRENT_TIMESTAMP(6)&lt;/code&gt;, which sets &lt;code&gt;updated_at&lt;/code&gt; to the current time whenever a row's values change, even when the &lt;code&gt;UPDATE&lt;/code&gt; does not set it. Both paths read it, but the diagram had no place for it, so a table written from the diagram lost it and the copy's &lt;code&gt;updated_at&lt;/code&gt; stopped moving. The diagram now keeps it on both paths, the field editor sets it on a &lt;code&gt;TIMESTAMP&lt;/code&gt; or &lt;code&gt;DATETIME&lt;/code&gt; column, and a rebuilt copy updates the column as the original does.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Falr5kxstf5kq6lh13fab.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Falr5kxstf5kq6lh13fab.webp" alt="Schemity's field dialog for updated_at on a three-table test diagram: TIMESTAMP with Precision 6, Default CURRENT_TIMESTAMP(6), and On update, set to the current time ticked, with the note that MySQL sets the column whenever the row's values change and a link to the MySQL documentation" width="799" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The fix changed the PostgreSQL post too. Mastodon's 92 &lt;code&gt;timestamp(6)&lt;/code&gt; columns had been written back as &lt;code&gt;timestamp&lt;/code&gt;, which PostgreSQL treats as the same type, since its default is 6; they now come back as declared, and that post was updated. Converting a diagram between databases had the opposite gap: a PostgreSQL &lt;code&gt;timestamp&lt;/code&gt; keeps 6 digits but became MySQL's bare &lt;code&gt;DATETIME&lt;/code&gt;, which keeps none. It now becomes &lt;code&gt;DATETIME(6)&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why did double(8,2) stop rounding?
&lt;/h3&gt;

&lt;p&gt;Because the precision and scale of a &lt;code&gt;DOUBLE&lt;/code&gt; were never written. &lt;code&gt;DOUBLE(8,2)&lt;/code&gt; rounds every value to two decimals as it is stored, so &lt;code&gt;1.239&lt;/code&gt; becomes &lt;code&gt;1.24&lt;/code&gt;; a plain &lt;code&gt;DOUBLE&lt;/code&gt; keeps &lt;code&gt;1.239&lt;/code&gt;. MySQL marks &lt;code&gt;FLOAT(M,D)&lt;/code&gt; and &lt;code&gt;DOUBLE(M,D)&lt;/code&gt; as &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/floating-point-types.html" rel="noopener noreferrer"&gt;deprecated&lt;/a&gt;, but a schema that has them still rounds. The field dialog already let you enter a precision and scale for &lt;code&gt;DOUBLE&lt;/code&gt;, and Import SQL dropped them while the MySQL export wrote them only for &lt;code&gt;DECIMAL&lt;/code&gt;. Both are fixed.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5t1197kzqd4g5wp5rf4y.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5t1197kzqd4g5wp5rf4y.webp" alt="Schemity's field dialog for recurring_quotes.discount in the Invoice Ninja diagram imported from its dump: a DOUBLE with Precision 8 and Scale 2 and a custom default of 0.00" width="799" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Which bugs did only the live path and a refresh find?
&lt;/h3&gt;

&lt;p&gt;Four, none of which a catalog diff of the import path could show:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An empty-string default.&lt;/strong&gt; Invoice Ninja's &lt;code&gt;countries.decimal_separator&lt;/code&gt; is &lt;code&gt;DEFAULT ''&lt;/code&gt;. MySQL reports that as an empty &lt;code&gt;column_default&lt;/code&gt;, and the live reader read it as no default, so a copy built from the live path stored &lt;code&gt;NULL&lt;/code&gt; where the original stores an empty string. It now reads &lt;code&gt;''&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A refresh straight after the import.&lt;/strong&gt; Import a dump, connect to the database it came from, and &lt;a href="https://schemity.com/doc/resync-database/" rel="noopener noreferrer"&gt;Refresh &amp;amp; merge&lt;/a&gt; should find nothing to change. Measured through the app's own merge code, called from a script, it planned 1,210 statements for Invoice Ninja and 537 for Sylius, because the two paths spelled the same schema differently: a &lt;code&gt;tinyint(1)&lt;/code&gt; default as &lt;code&gt;'0'&lt;/code&gt; against &lt;code&gt;FALSE&lt;/code&gt;, a column's character set as empty against &lt;code&gt;utf8mb4&lt;/code&gt;, an index type as blank against &lt;code&gt;BTREE&lt;/code&gt;, and &lt;code&gt;CHAR&lt;/code&gt; against &lt;code&gt;CHARACTER&lt;/code&gt;. Each is now read the same way on both paths, and both refreshes list nothing. Mastodon and Saleor, run through the same check, list nothing too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Import SQL over the live diagram.&lt;/strong&gt; Taking these screenshots, importing Invoice Ninja's dump onto the diagram read from its live database marked all 80 tables Updated, although a migration between the two is zero statements. The preview compared the two tables field by field, so the same spelling differences counted as changes. It now asks the planner Refresh &amp;amp; merge uses which tables a migration would touch, and marks the other tables Unchanged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Column order, through the app.&lt;/strong&gt; Found later, while taking the SQLite post's screenshots, and invisible to the scripts here because they skip a step the app takes when it reads a table: the app lists a table's fields key first, foreign keys next and timestamps last, and wrote &lt;code&gt;CREATE TABLE&lt;/code&gt; in that order, so a copy built from the diagram met a &lt;code&gt;SELECT *&lt;/code&gt; or an &lt;code&gt;INSERT&lt;/code&gt; without a column list with its columns in another order. The diagram now remembers each column's position as the table declares it and writes that order, while the canvas keeps its own; a column added in the diagram goes last, where &lt;code&gt;ADD COLUMN&lt;/code&gt; puts it. Read and written back the way the app does it, 74 of Invoice Ninja's 80 tables and 81 of Sylius's 115 came back in a different column order before this fix.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does the round trip give you now?
&lt;/h2&gt;

&lt;p&gt;The same database, on both paths, for both schemas.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Import SQL&lt;/th&gt;
&lt;th&gt;Live connection&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Invoice Ninja: catalog differences&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invoice Ninja: probes that match the original&lt;/td&gt;
&lt;td&gt;11 of 11&lt;/td&gt;
&lt;td&gt;11 of 11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sylius: catalog differences&lt;/td&gt;
&lt;td&gt;16 column comments&lt;/td&gt;
&lt;td&gt;16 column comments&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sylius: probes that match the original&lt;/td&gt;
&lt;td&gt;8 of 8&lt;/td&gt;
&lt;td&gt;8 of 8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A refresh from the original database straight after importing its dump lists no changes for either schema.&lt;/p&gt;

&lt;p&gt;The other direction holds in the app's window: Import SQL of the dump onto the diagram read from the live database finds nothing to change.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsvk92gj5r8mz90u2leke.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsvk92gj5r8mz90u2leke.webp" alt="Schemity's Import SQL summary for Invoice Ninja's mysqldump on the diagram read from the live database: the last rows of the table list, from subscriptions to webhooks, each marked Unchanged, and the button Import (0 new, 0 updated) disabled; the status bar reads Entities: 80, Relations: 138 (+132 virtual hidden)" width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;All 437 &lt;code&gt;UNSIGNED&lt;/code&gt; columns, 141 &lt;code&gt;timestamp(6)&lt;/code&gt; columns, 3 prefix indexes, 13 &lt;code&gt;ENUM&lt;/code&gt; value lists, 279 foreign keys with their actions and every table's and column's collation come back as they were, including the one Sylius table on &lt;code&gt;utf8mb4_0900_ai_ci&lt;/code&gt;, whose &lt;code&gt;NO PAD&lt;/code&gt; probe accepts the trailing-space pair in the original and in both copies.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2k8ziyo8i062bos40aax.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2k8ziyo8i062bos40aax.webp" alt="Schemity's Entity SQL dialog for Invoice Ninja's clients table, in the diagram imported from its dump, scrolled to the end: text columns with CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, created_at, updated_at and deleted_at as TIMESTAMP(6), the table's ENGINE=InnoDB CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci, and CREATE INDEX clients_client_hash(20)_index ON clients (client_hash(20))" width="800" height="652"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What still differs, and why?
&lt;/h2&gt;

&lt;p&gt;Only Sylius's column comments, and they are left out on purpose. Schemity treats a field's description as the diagram's own notes and does not write it to the database: on MySQL a comment can only be changed by restating the whole column with &lt;code&gt;MODIFY COLUMN&lt;/code&gt;, and an edit to a note should never be the riskiest statement in a migration. The 16 comments are Doctrine type hints such as &lt;code&gt;(DC2Type:datetime_immutable)&lt;/code&gt;. Sylius 2.3 runs on Doctrine DBAL 4.5, which &lt;a href="https://github.com/doctrine/dbal/blob/4.0.0/UPGRADE.md" rel="noopener noreferrer"&gt;no longer writes or reads type comments&lt;/a&gt;; its upgrade notes say doctrine/migrations should generate a migration dropping them, so a copy without them is what Doctrine itself would arrive at.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this benchmark does not show
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The app's window.&lt;/strong&gt; These runs called Schemity's import, reader, export and comparison code from scripts on a development build. The screenshots are from the app's window on the same development build, and the field dialog for &lt;code&gt;ON UPDATE&lt;/code&gt; is on a small test table, since neither schema has that clause.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The live path before the fixes, in full.&lt;/strong&gt; The "before" column covers Import SQL. On the live path, Invoice Ninja's script was refused at the same prefix index and Sylius differed only in its comments; Invoice Ninja's live copy was not patched and probed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data.&lt;/strong&gt; The originals are empty. A schema that matches is the precondition for moving data, not proof that a migration of a full database is safe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Views, triggers, routines and &lt;code&gt;CHECK&lt;/code&gt; constraints.&lt;/strong&gt; Neither schema has any, so nothing here tests them on MySQL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MariaDB.&lt;/strong&gt; Everything ran on MySQL 8.4.11; MariaDB's collations and defaults differ.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Other databases.&lt;/strong&gt; SQL Server and SQLite have their own posts, with &lt;a href="https://schemity.com/blog/sqlserver-round-trip-bitwarden-smartstore/" rel="noopener noreferrer"&gt;Bitwarden and Smartstore&lt;/a&gt; and &lt;a href="https://schemity.com/blog/sqlite-round-trip-campfire-jellyfin/" rel="noopener noreferrer"&gt;Campfire and Jellyfin&lt;/a&gt;, and Snowflake &lt;a href="https://schemity.com/blog/snowflake-round-trip-tpcds/" rel="noopener noreferrer"&gt;with TPC-DS&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schemas this size are not a stress test.&lt;/strong&gt; 80 and 115 tables are typical; a thousand-table schema is covered in &lt;a href="https://schemity.com/blog/database-diagram-1000-tables-memory-cpu/" rel="noopener noreferrer"&gt;a separate post&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to rerun it
&lt;/h2&gt;

&lt;p&gt;Create each schema with its framework against an empty MySQL 8.4 database, take &lt;code&gt;mysqldump --no-data --skip-comments --routines --triggers&lt;/code&gt;, put it through Schemity on either path, export the SQL and apply it to an empty database. Then list both catalogs with a query like this one, run with &lt;code&gt;mysql -N -D &amp;lt;database&amp;gt;&lt;/code&gt;, and diff the output:&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="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&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;CONCAT_WS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;' | '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'table'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table_collation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;table_comment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tables&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;table_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'BASE TABLE'&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;CONCAT_WS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;' | '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'column'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;column_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;IF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;is_nullable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'YES'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'null'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'not null'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;IFNULL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CONCAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'default '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;column_default&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;'no default'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;extra&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IFNULL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;collation_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'-'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;column_comment&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IFNULL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;generation_expression&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;CONCAT_WS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;' | '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'index'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;non_unique&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="s1"&gt;'unique'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'non-unique'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;index_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;GROUP_CONCAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CONCAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;IFNULL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expression&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;IFNULL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CONCAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'('&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sub_part&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;')'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;),&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;collation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'D'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;' desc'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;seq_in_index&lt;/span&gt; &lt;span class="n"&gt;SEPARATOR&lt;/span&gt; &lt;span class="s1"&gt;', '&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;statistics&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;non_unique&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;index_type&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;CONCAT_WS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;' | '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'fk'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;GROUP_CONCAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;column_name&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;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ordinal_position&lt;/span&gt; &lt;span class="n"&gt;SEPARATOR&lt;/span&gt; &lt;span class="s1"&gt;', '&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;referenced_table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;GROUP_CONCAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;referenced_column_name&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;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ordinal_position&lt;/span&gt; &lt;span class="n"&gt;SEPARATOR&lt;/span&gt; &lt;span class="s1"&gt;', '&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;CONCAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'on delete '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delete_rule&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;CONCAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'on update '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;update_rule&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;key_column_usage&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;
  &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;referential_constraints&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_schema&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_name&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;referenced_table_name&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="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;referenced_table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delete_rule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;update_rule&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;CONCAT_WS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;' | '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'check'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;check_clause&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;check_constraints&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
  &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;table_constraints&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;
    &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_schema&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_name&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;constraint_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'CHECK'&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;constraint_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;catalog&lt;/span&gt; &lt;span class="k"&gt;ORDER&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For behaviour, run a few statements per rule inside &lt;code&gt;START TRANSACTION&lt;/code&gt; and &lt;code&gt;ROLLBACK&lt;/code&gt; on the original and on the copy, end each with a &lt;code&gt;SELECT&lt;/code&gt; of what was stored, and compare both the step MySQL refuses at and the values it reads back. A database that matches on refusals alone can still be rounding every timestamp.&lt;/p&gt;

</description>
      <category>mysql</category>
      <category>database</category>
      <category>laravel</category>
      <category>symfony</category>
    </item>
    <item>
      <title>Round-Tripping Mastodon and Saleor Through a Database Diagram: What Broke and What We Fixed</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Tue, 20 Oct 2026 14:51:21 +0000</pubDate>
      <link>https://dev.to/tbson87/round-tripping-mastodon-and-saleor-through-a-database-diagram-what-broke-and-what-we-fixed-ofn</link>
      <guid>https://dev.to/tbson87/round-tripping-mastodon-and-saleor-through-a-database-diagram-what-broke-and-what-we-fixed-ofn</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://schemity.com" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt;, a desktop ERD tool - this post is from our blog and uses it for the examples.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; We put two real PostgreSQL schemas, Mastodon (Rails) and Saleor (Django), through Schemity and rebuilt a database from the SQL it wrote. Before this round of fixes PostgreSQL refused that SQL for both, and once patched by hand the copies refused rows the originals accept. After the fixes the rebuilt copies match their originals in every table, key, constraint and index, and behave the same in 16 probes. What a diagram does not hold, Mastodon's own function and Saleor's four extensions, is no longer a hand-written step: Import SQL keeps it and Save runs it first.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A database diagram that reads a schema and writes it back has one job: give you the same database. To test that, we took two real open-source schemas, Mastodon v4.7.3 (Rails, 118 tables) and Saleor 3.23.41 (Django, 148 tables), put each through Schemity both from its &lt;code&gt;pg_dump&lt;/code&gt; and from the live database, and rebuilt a database from the SQL Schemity wrote. Before this round of fixes, PostgreSQL refused the SQL Schemity wrote for both schemas. After them, the rebuilt copies match the originals in every table, column, key, constraint and index, apart from spelling, and accept and refuse exactly the same rows in 16 behaviour probes.&lt;/p&gt;

&lt;p&gt;Schemity is database design software that reads your live database, shows the impact of every schema change before it runs, and keeps the diagram as a file in Git. Reading a database you did not design is how most people start with it: you reverse engineer the PostgreSQL schema to an ERD to understand your schema, and later generate SQL from that diagram. Both directions are only as good as what survives the trip, so this post measures exactly that, including what broke.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which two schemas, and which two paths?
&lt;/h2&gt;

&lt;p&gt;Two production schemas, each created by its own framework rather than written for the test, and chosen because they use different parts of PostgreSQL.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Mastodon v4.7.3&lt;/th&gt;
&lt;th&gt;Saleor 3.23.41&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What it is&lt;/td&gt;
&lt;td&gt;Federated social network&lt;/td&gt;
&lt;td&gt;E-commerce platform&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Created by&lt;/td&gt;
&lt;td&gt;Rails, &lt;code&gt;rails db:schema:load&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Django, &lt;code&gt;manage.py migrate&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tables&lt;/td&gt;
&lt;td&gt;118&lt;/td&gt;
&lt;td&gt;148&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Columns&lt;/td&gt;
&lt;td&gt;1,018&lt;/td&gt;
&lt;td&gt;1,348&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Foreign keys&lt;/td&gt;
&lt;td&gt;156&lt;/td&gt;
&lt;td&gt;266, 265 of them &lt;code&gt;DEFERRABLE INITIALLY DEFERRED&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;CHECK&lt;/code&gt; constraints&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;43&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unique constraints&lt;/td&gt;
&lt;td&gt;0 (uniqueness is in indexes)&lt;/td&gt;
&lt;td&gt;128&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Indexes besides primary keys&lt;/td&gt;
&lt;td&gt;217&lt;/td&gt;
&lt;td&gt;616&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Partial (&lt;code&gt;WHERE&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;44&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;On an expression&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;With an operator class&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;62&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;With &lt;code&gt;DESC&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Keys numbered by the database&lt;/td&gt;
&lt;td&gt;106 serial&lt;/td&gt;
&lt;td&gt;132 &lt;code&gt;GENERATED BY DEFAULT AS IDENTITY&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Also has&lt;/td&gt;
&lt;td&gt;2 views, 1 function&lt;/td&gt;
&lt;td&gt;4 extensions, 1 function&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Rails leans on serial keys and on partial and sorted indexes; Django on identity keys, deferred foreign keys and operator classes for search. Between them they cover most of what a framework writes into a PostgreSQL schema.&lt;/p&gt;

&lt;p&gt;Mastodon's 118 tables, imported from its dump and grouped into 17 legends by the models in Mastodon's own source (accounts, statuses, the social graph, moderation, federation and so on), look like this. &lt;a href="https://schemity.com/images/blog/postgres-round-trip-mastodon-saleor/mastodon-full.webp" rel="noopener noreferrer"&gt;Open the full-size diagram&lt;/a&gt; to read the table names.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhqtz28asc50sa18k12xw.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhqtz28asc50sa18k12xw.webp" alt="Schemity diagram of Mastodon v4.7.3's 118 tables, imported from pg_dump and grouped into 17 coloured legends taken from Mastodon's model files: Accounts, Statuses, Social Graph, Users &amp;amp; Sign-in, OAuth, Media &amp;amp; Polls, Tags, Trends, Notifications &amp;amp; Filters, Moderation, Blocks &amp;amp; Allow Lists, Collections, Federation, Instance, Follow Suggestions, Imports &amp;amp; Exports and Housekeeping" width="800" height="777"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The status bar of that diagram says 168 relations, not 156. The extra 12 are relationships Schemity inferred from column names that have no foreign key in the database. They are drawn dashed and are never written out as constraints, so the exported script holds exactly Mastodon's 156 foreign keys. A schema this size is not read by shrinking it: the minimap shows where every legend sits, and search finds a column across all 118 tables. &lt;a href="https://schemity.com/blog/why-schemity-has-no-zoom/" rel="noopener noreferrer"&gt;Why the canvas works that way&lt;/a&gt; has its own post.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvluywibanbw051ynv03r.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvluywibanbw051ynv03r.webp" alt="Part of the Mastodon diagram at full size, with the Imports &amp;amp; Exports legend in view and the minimap in the bottom right corner showing all 17 legends of the 118-table schema; the status bar reads Entities: 118, Relations: 168" width="800" height="485"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F39fblzdpp8jygv33sj52.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F39fblzdpp8jygv33sj52.webp" alt="Schemity's search panel over the Mastodon diagram with the query account_id, listing the account_id column of account_aliases, account_conversations, account_deletion_requests and dozens more tables, each as table, column and BIGINT" width="800" height="495"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each went through Schemity on two paths, which run different code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Import SQL:&lt;/strong&gt; the &lt;code&gt;pg_dump --schema-only&lt;/code&gt; file read by Schemity's SQL parser, with no database connection.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live connection:&lt;/strong&gt; Schemity connected to the database and read PostgreSQL's catalog.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both paths end the same way: the diagram's SQL exported and applied to an empty database. The four runs were done in the app, in a development build with the fixes described below. The originals ran on PostgreSQL 17.11 and the rebuilt copies on PostgreSQL 18.3.&lt;/p&gt;

&lt;p&gt;Import SQL lists every table it found before anything is added, then what it did with the rest. Mastodon's tables call a function a diagram does not hold, and the summary says it kept the SQL that creates it:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb9425qnngnc3cinpo112.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fb9425qnngnc3cinpo112.webp" alt="Schemity's Import SQL summary for Mastodon's pg_dump: the last rows of the table list, each marked New, then the lines Kept the setup SQL for timestamp_id(), which 7 tables call, and the diagram's SQL runs it first; 2 views are not imported (instances, user_ips); Skipped 12 statements; and the button Import (118 new, 0 updated)" width="800" height="428"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Mastodon's ids come from its own &lt;code&gt;timestamp_id()&lt;/code&gt;, which reads seven sequences no column owns. Import SQL keeps the dump's own text for the function and those sequences as setup SQL. It writes it before the tables in View SQL, and when Save migrates to a database that lacks the function, puts it at the top of the migration, with each sequence as &lt;code&gt;CREATE SEQUENCE IF NOT EXISTS&lt;/code&gt; so one the database already has is left alone:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg8xyp1k49m1rq8ict6on.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg8xyp1k49m1rq8ict6on.webp" alt="Schemity's Migration confirm dialog for an empty database, pg_demo_imported, reporting no data loss, failures or dependent objects, whose SQL begins with CREATE SEQUENCE IF NOT EXISTS public.accounts_id_seq and the other sequences timestamp_id() reads, ahead of the tables" width="800" height="496"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Saleor's tables call no function of their own, but its search indexes need four extensions, among them &lt;code&gt;pg_trgm&lt;/code&gt;. Import SQL keeps them, and the diagram's SQL creates them first:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg1tv9gvc6xxnu591tndo.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg1tv9gvc6xxnu591tndo.webp" alt="Schemity's Import SQL summary for Saleor's pg_dump: the last rows of the table list, each marked New, then Kept 4 extensions, which the diagram's SQL creates first: btree_gin, hstore, pg_trgm, pgcrypto, Skipped 1 statement, and the button Import (148 new, 0 updated)" width="800" height="496"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdwapmd77umf5olecq9a3.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdwapmd77umf5olecq9a3.webp" alt="Schemity's Migration confirm dialog for an empty database whose SQL begins with CREATE EXTENSION IF NOT EXISTS for btree_gin, hstore, pg_trgm and pgcrypto, then CREATE TABLE account_address with its id GENERATED BY DEFAULT AS IDENTITY" width="800" height="496"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;None of that existed when the benchmark started. The first runs needed a hand-written prelude on each empty database: the function and its seven sequences copied from Mastodon's dump, and four &lt;code&gt;CREATE EXTENSION&lt;/code&gt; lines for Saleor. The comparisons below were measured that way. Once Import SQL kept the setup SQL itself, both dumps went through the app again, imported and migrated to an empty database with Save and nothing run by hand: all 118 of Mastodon's tables and 148 of Saleor's came up, and all 16 probes still match their originals.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you prove a rebuilt schema is the same?
&lt;/h2&gt;

&lt;p&gt;Two ways, and they answer different questions: does the database describe itself the same way, and does it behave the same way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The catalog diff.&lt;/strong&gt; Each original was created by its own framework, not by us: Mastodon with &lt;code&gt;rails db:schema:load&lt;/code&gt; in the official &lt;code&gt;ghcr.io/mastodon/mastodon:v4.7.3&lt;/code&gt; image, Saleor with &lt;code&gt;manage.py migrate&lt;/code&gt; in &lt;code&gt;ghcr.io/saleor/saleor:3.23.41&lt;/code&gt;, both on PostgreSQL 17.11. The schema then went into Schemity two ways, as a &lt;code&gt;pg_dump --schema-only&lt;/code&gt; file through Import SQL and as a live connection, and Schemity's exported SQL was applied to an empty database. A query over PostgreSQL's own catalog then lists, for both databases, one line per fact:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;every column with its type as &lt;code&gt;format_type&lt;/code&gt; prints it, &lt;code&gt;NOT NULL&lt;/code&gt;, its default and its identity&lt;/li&gt;
&lt;li&gt;every primary key, foreign key, unique and &lt;code&gt;CHECK&lt;/code&gt; constraint as &lt;code&gt;pg_get_constraintdef&lt;/code&gt; prints it&lt;/li&gt;
&lt;li&gt;every index as &lt;code&gt;pg_indexes&lt;/code&gt; prints it, with its own name left out&lt;/li&gt;
&lt;li&gt;every sequence with its type, its start and the column that owns it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The two lists are diffed line by line. A difference is either a real change or the same thing spelled two ways, such as &lt;code&gt;DEFAULT now()&lt;/code&gt; and &lt;code&gt;DEFAULT CURRENT_TIMESTAMP&lt;/code&gt;, which are the same time, and each one is classified by hand in the results.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The behaviour probes.&lt;/strong&gt; A catalog can agree while a database still behaves differently, so each copy also gets 16 probes, eight per app, run against the original and every copy. A probe is a few statements in one transaction that is rolled back, so nothing stays: insert an account with its own id, insert two usernames that differ only in case, insert a tax rate before the tax class it points to. Its outcome is "accepted" or "refused at step N" with PostgreSQL's reason, and a copy passes when its outcome is the original's.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;What it compares&lt;/th&gt;
&lt;th&gt;What it catches&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Catalog diff&lt;/td&gt;
&lt;td&gt;Columns, keys, constraints, indexes, as PostgreSQL reports them&lt;/td&gt;
&lt;td&gt;Anything missing, added or changed in the schema&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Behaviour probes&lt;/td&gt;
&lt;td&gt;Which rows each database accepts and refuses&lt;/td&gt;
&lt;td&gt;A difference the catalog reads past, or one that only matters to the data&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Data is not part of this test. Both originals hold almost none: Saleor's migrations insert a default customer type, which one probe has to set aside first, and Mastodon's insert nothing the probes touch.&lt;/p&gt;

&lt;h2&gt;
  
  
  What failed before the fixes?
&lt;/h2&gt;

&lt;p&gt;Everything below is from Schemity as of 14 October 2026, through Import SQL. Rebuilding the live path at that point would have meant a second full build of the desktop app, so the "before" column has the import path only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Neither exported script ran.&lt;/strong&gt; Schemity imported both schemas, but PostgreSQL refused the SQL it wrote for each at the first &lt;code&gt;double precision&lt;/code&gt; column, which Schemity had written as &lt;code&gt;DOUBLE&lt;/code&gt;, a MySQL name: 5 columns in Mastodon, 9 in Saleor. With that patched by hand, and Saleor's one empty array default, written as the text &lt;code&gt;'ARRAY[]'&lt;/code&gt;, patched too, both scripts ran, and the probes showed what the database would have done.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Probe&lt;/th&gt;
&lt;th&gt;Original&lt;/th&gt;
&lt;th&gt;Before the fixes&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mastodon: a row with its own id in a serial table&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;td&gt;refused&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mastodon: usernames that differ only in case&lt;/td&gt;
&lt;td&gt;refused&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;td&gt;refused&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mastodon: two statuses with the same URI&lt;/td&gt;
&lt;td&gt;refused&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;td&gt;refused&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Saleor: any row that gives its own id&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;td&gt;refused&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Saleor: a rate inserted before its tax class, in one transaction&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;td&gt;refused&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Saleor: one country's rates under two tax classes&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;td&gt;refused&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Saleor: two customer types that are not the default&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;td&gt;refused&lt;/td&gt;
&lt;td&gt;accepted&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Mastodon's other five probes passed even before the fixes. Saleor's first failure hid the rest, since every probe that gives a row its own id stopped at step one, so its last three rows come from a second run that lets the database pick the ids. Each failure had its own cause.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why did serial ids come back as &lt;code&gt;GENERATED ALWAYS&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;Rails makes a key &lt;code&gt;bigserial&lt;/code&gt;, and &lt;code&gt;pg_dump&lt;/code&gt; writes that as a plain &lt;code&gt;bigint&lt;/code&gt; column plus a separate &lt;code&gt;ALTER TABLE ... ALTER COLUMN id SET DEFAULT nextval('accounts_id_seq')&lt;/code&gt;. Import SQL ignored that second statement, so the column arrived with no default, and the export followed its old rule for a lone integer key with none: &lt;code&gt;GENERATED ALWAYS AS IDENTITY&lt;/code&gt;. 104 of Mastodon's 106 keys came back that way, and &lt;code&gt;ALWAYS&lt;/code&gt; refuses a row that gives its own id, which is exactly what fixtures, seed scripts and copied data do. The other two keys lost their default altogether. Import SQL now reads the &lt;code&gt;SET DEFAULT&lt;/code&gt;, and a column on its own sequence is written back as &lt;code&gt;BIGSERIAL&lt;/code&gt;, which creates the same sequence, default and ownership. The trade-offs between the two are in &lt;a href="https://schemity.com/blog/postgres-serial-vs-identity/" rel="noopener noreferrer"&gt;PostgreSQL serial vs identity&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;On the canvas, a key the database numbers reads &lt;code&gt;auto&lt;/code&gt;, whether it is a serial or an identity, and a key the application assigns stays blank. A column that draws from a sequence of its own, such as an invoice number, shows &lt;code&gt;nextval&lt;/code&gt;, because it is a default and not a key.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy4o90ear41lzl00kzipk.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy4o90ear41lzl00kzipk.webp" alt="Five tables on the Schemity canvas: the id columns of accounts (bigserial), invoices (GENERATED ALWAYS), tax_class and tax_rate (GENERATED BY DEFAULT) all read auto, the id of statuses, which the application assigns, is blank, invoices.number reads nextval, and accounts.created_at reads NOW()" width="800" height="288"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What does Django's &lt;code&gt;BY DEFAULT&lt;/code&gt; identity need from a diagram?
&lt;/h3&gt;

&lt;p&gt;To be recorded at all. Since Django 4.1, a key is &lt;code&gt;GENERATED BY DEFAULT AS IDENTITY&lt;/code&gt;: the database numbers it, but a row may still bring its own id. &lt;code&gt;pg_dump&lt;/code&gt; writes each one as &lt;code&gt;ALTER TABLE ... ADD GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME ...)&lt;/code&gt;, and the SQL parser behind Import SQL cannot read the &lt;code&gt;SEQUENCE NAME&lt;/code&gt; option, so all 132 of Saleor's statements went unread, and the export gave 133 keys &lt;code&gt;GENERATED ALWAYS&lt;/code&gt;. A diagram now records each key's identity as &lt;code&gt;ALWAYS&lt;/code&gt;, &lt;code&gt;BY DEFAULT&lt;/code&gt; or none, from the live database and from a dump, writes it back as it was, and shows it in the field dialog as an Auto increment checkbox with the kind beside it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6vt6xvh576uqfiqghsvu.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6vt6xvh576uqfiqghsvu.webp" alt="Schemity's field dialog for tax_class.id, an INTEGER column with PK and Auto increment ticked and Identity set to Generated by default" width="799" height="425"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does every Django foreign key say &lt;code&gt;DEFERRABLE INITIALLY DEFERRED&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;So a transaction can insert a child row before its parent, the way &lt;code&gt;loaddata&lt;/code&gt; and Django fixtures load. 265 of Saleor's 266 foreign keys are declared that way, and before the fixes all 265 came back as ordinary keys, checked by each statement, so the rate inserted before its tax class was refused at once. The diagram now records when each key is checked, and the relationship dialog shows it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx8gtdjlku7drj0wk3j5s.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fx8gtdjlku7drj0wk3j5s.webp" alt="Schemity's Update relation dialog for tax_class.id to tax_rate.tax_class_id, constraint tax_rate_tax_class_id_fkey, with On delete and On update set to NO ACTION and Checked set to At commit (deferred)" width="800" height="347"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens to a partial unique index?
&lt;/h3&gt;

&lt;p&gt;It used to become a full one, which is the dangerous direction: the copy refuses rows the original accepts. Saleor allows one default tax rate per country with &lt;code&gt;CREATE UNIQUE INDEX ... ON tax_taxclasscountryrate (country) WHERE (tax_class_id IS NULL)&lt;/code&gt;. Without its condition that index makes &lt;code&gt;country&lt;/code&gt; unique across the whole table, so a second rate for the same country, under a different tax class, was refused. Saleor's one-default customer type went the same way, and a &lt;code&gt;UNIQUE NULLS NOT DISTINCT&lt;/code&gt; rule went the other, losing the clause that makes &lt;code&gt;NULL&lt;/code&gt; count as a duplicate. How a unique index differs from a unique constraint in the first place is in &lt;a href="https://schemity.com/blog/postgres-unique-constraint-vs-unique-index/" rel="noopener noreferrer"&gt;unique constraint vs unique index&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8v2x9776ty9vggvef4ya.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8v2x9776ty9vggvef4ya.webp" alt="Schemity's index dialog for one_default_rate_per_country on tax_rate: Fields country, Where (tax_class_id IS NULL), Type btree, Unique ticked; behind it the table's index list shows the same index as country WHERE (tax_class_id IS NULL)" width="799" height="510"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you keep &lt;code&gt;lower(username)&lt;/code&gt; and &lt;code&gt;text_pattern_ops&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;By keeping the index's key list as SQL when its columns alone do not describe it. Mastodon makes usernames unique regardless of case with &lt;code&gt;UNIQUE (lower(username), COALESCE(lower(domain), ''))&lt;/code&gt;, an index on expressions, and the old diagram held only column names, so the rule was dropped and the copy accepted &lt;code&gt;Alice&lt;/code&gt; beside &lt;code&gt;alice&lt;/code&gt;. Seven other Mastodon indexes use an operator class, &lt;code&gt;text_pattern_ops&lt;/code&gt;, which the parser could not read either, so all seven were lost, three of them unique, which is why the copy accepted two statuses with the same URI. Saleor has 62 indexes with an operator class, among them 19 trigram &lt;code&gt;GIN&lt;/code&gt; indexes for search. The diagram now keeps the expression, the operator class, a sort order such as &lt;code&gt;id DESC&lt;/code&gt;, the &lt;code&gt;WHERE&lt;/code&gt; condition and &lt;code&gt;NULLS NOT DISTINCT&lt;/code&gt;, and the index dialog shows them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F873sa5od2qyegfl95s3x.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F873sa5od2qyegfl95s3x.webp" alt="Schemity's index dialog for accounts_username_domain_lower: Keys lower((username)::text), COALESCE(lower((domain)::text), ''::text), Type btree, Unique ticked; behind it the accounts index list also shows accounts_uri as uri text_pattern_ops WHERE (uri IS NOT NULL)" width="799" height="510"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The keys read &lt;code&gt;lower((username)::text)&lt;/code&gt; rather than &lt;code&gt;lower(username)&lt;/code&gt; because that is how PostgreSQL stores the expression, and the diagram keeps what the database reports.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which bugs did only real dumps find?
&lt;/h3&gt;

&lt;p&gt;The ones no hand-written test schema had:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Newer versions of &lt;code&gt;pg_dump&lt;/code&gt; wrap the file in psql's &lt;code&gt;\restrict&lt;/code&gt; command, which is not SQL and has no semicolon, so it joined the statement after it.&lt;/li&gt;
&lt;li&gt;Saleor writes extension operator classes with their schema, &lt;code&gt;public.gin_trgm_ops&lt;/code&gt;, which needed its own handling.&lt;/li&gt;
&lt;li&gt;The live reader reported a nullable column with no default as &lt;code&gt;DEFAULT NULL&lt;/code&gt;, so the export wrote &lt;code&gt;DEFAULT NULL&lt;/code&gt; on 171 of Saleor's columns.&lt;/li&gt;
&lt;li&gt;Import SQL turned every &lt;code&gt;CREATE UNIQUE INDEX&lt;/code&gt; into a &lt;code&gt;UNIQUE&lt;/code&gt; constraint, so 69 of Mastodon's unique indexes came back as constraints. It is the same rule, so no probe noticed, but not the same object: a later Rails migration that runs &lt;code&gt;remove_index&lt;/code&gt; on one would fail, because PostgreSQL will not drop an index a constraint owns. A unique index now stays an index, as the live reader already kept it; on the canvas both still show as unique.&lt;/li&gt;
&lt;li&gt;A diagram imported from Mastodon's dump and then refreshed from the database it built reported 70 columns as changed in the database, because the two paths spell &lt;code&gt;false&lt;/code&gt; and &lt;code&gt;FALSE&lt;/code&gt;, or &lt;code&gt;DECIMAL&lt;/code&gt; and &lt;code&gt;NUMERIC&lt;/code&gt;, differently. Comparing the meaning instead of the spelling took the planned migration from 71 statements to none.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these broke a test, because every test schema had been written by hand to cover a feature. A framework's real output was the first input that had all of them at once.&lt;/p&gt;

&lt;p&gt;One more turned up later, while taking the screenshots for &lt;a href="https://schemity.com/blog/sqlite-round-trip-campfire-jellyfin/" rel="noopener noreferrer"&gt;the SQLite post&lt;/a&gt;, which the scripts here could not see because they skip a step the app takes when it reads a table: the app lists a table's fields key first, foreign keys next and timestamps last, and wrote &lt;code&gt;CREATE TABLE&lt;/code&gt; in that order, so a copy built from the diagram met a &lt;code&gt;SELECT *&lt;/code&gt; or an &lt;code&gt;INSERT&lt;/code&gt; without a column list with its columns in another order. The diagram now remembers each column's position as the table declares it and writes that order, while the canvas keeps its own; a column added in the diagram goes last, where &lt;code&gt;ADD COLUMN&lt;/code&gt; puts it. Read and written back the way the app does it, 102 of Mastodon's 118 tables and 125 of Saleor's 148 came back in a different column order before this fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the round trip give you now?
&lt;/h2&gt;

&lt;p&gt;The same database, on both paths, for both schemas. Each line below is one run in the app: the schema went in through one path, Schemity wrote the SQL, and the rebuilt copy was compared with the original.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Import SQL&lt;/th&gt;
&lt;th&gt;Live connection&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Mastodon: catalog differences&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mastodon: sequences&lt;/td&gt;
&lt;td&gt;all 113 identical&lt;/td&gt;
&lt;td&gt;all 113 identical&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mastodon: probes that match the original&lt;/td&gt;
&lt;td&gt;8 of 8&lt;/td&gt;
&lt;td&gt;8 of 8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Saleor: catalog differences&lt;/td&gt;
&lt;td&gt;4, all spelling&lt;/td&gt;
&lt;td&gt;5, all spelling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Saleor: sequences&lt;/td&gt;
&lt;td&gt;111 identical, 24 renamed (below)&lt;/td&gt;
&lt;td&gt;111 identical, 24 renamed (below)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Saleor: probes that match the original&lt;/td&gt;
&lt;td&gt;8 of 8&lt;/td&gt;
&lt;td&gt;8 of 8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A first version of this table gave Mastodon 92 differences, all &lt;code&gt;timestamp(6)&lt;/code&gt; columns written back as &lt;code&gt;timestamp&lt;/code&gt;. On PostgreSQL those are one type, because 6 digits is the default, so nothing was lost. The &lt;a href="https://schemity.com/blog/mysql-round-trip-invoice-ninja-sylius/" rel="noopener noreferrer"&gt;MySQL round trip&lt;/a&gt; showed the same gap is not harmless there: a bare MySQL &lt;code&gt;timestamp&lt;/code&gt; keeps whole seconds, so &lt;code&gt;timestamp(6)&lt;/code&gt; written back that way rounds every microsecond away. Schemity now keeps a time column's precision as it was declared, on every database, and Mastodon's 92 columns come back as &lt;code&gt;timestamp(6)&lt;/code&gt;. That rerun went through Schemity's core on the development build, not through the app.&lt;/p&gt;

&lt;p&gt;Both paths start from the original database Rails created. A first version of this post read Mastodon's live path from the Import SQL copy instead, which made its number a repeat of the import's rather than its own; the line above is from the original.&lt;/p&gt;

&lt;p&gt;All 132 identity keys come back &lt;code&gt;GENERATED BY DEFAULT AS IDENTITY&lt;/code&gt;, all 106 serial keys &lt;code&gt;BIGSERIAL&lt;/code&gt;, all 265 deferred foreign keys &lt;code&gt;DEFERRABLE INITIALLY DEFERRED&lt;/code&gt;, and every partial, expression and sorted index with its condition, key list and operator class.&lt;/p&gt;

&lt;h2&gt;
  
  
  What still differs, and why?
&lt;/h2&gt;

&lt;p&gt;Two kinds of difference are left: the same thing spelled another way, and objects the diagram does not hold.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Spelled differently, behaving the same:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;In the original&lt;/th&gt;
&lt;th&gt;In the rebuilt copy&lt;/th&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DEFAULT now()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DEFAULT CURRENT_TIMESTAMP&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2 Saleor columns; both are the transaction's start time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DEFAULT ARRAY[]::character varying[]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;DEFAULT '{}'::character varying[]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;1 Saleor column; both are an empty array&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;UNIQUE NULLS NOT DISTINCT&lt;/code&gt; constraint&lt;/td&gt;
&lt;td&gt;Unique index with &lt;code&gt;NULLS NOT DISTINCT&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1 Saleor rule, the same index under it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DEFAULT NULL::character varying&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;no default&lt;/td&gt;
&lt;td&gt;1 Saleor column, live path only; a column with no default is &lt;code&gt;NULL&lt;/code&gt; anyway&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;An identity sequence named after a table's old name, &lt;code&gt;account_serviceaccount_id_seq&lt;/code&gt; on &lt;code&gt;app_app.id&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The name PostgreSQL gives it now, &lt;code&gt;app_app_id_seq&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;24 Saleor keys, same type, start and column; this matters only to code that names the sequence itself&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Not held by the diagram as objects:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Functions.&lt;/strong&gt; Mastodon numbers seven tables with its own &lt;code&gt;timestamp_id()&lt;/code&gt; function, and the column defaults that call it are kept. Without the function PostgreSQL stops at the first such table with &lt;code&gt;function public.timestamp_id(text) does not exist&lt;/code&gt;. From a dump, Import SQL now keeps the function and the seven sequences it reads as setup SQL and runs them first, as shown above. A function read from a live database is only named: View SQL warns that the script calls a function it does not create.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Views.&lt;/strong&gt; A &lt;code&gt;CREATE VIEW&lt;/code&gt; statement names its columns' types nowhere, so Import SQL skips Mastodon's two views and lists them as skipped. A live connection reads them, as read-only tables in the diagram, and they are not exported as SQL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A sequence's options.&lt;/strong&gt; A sequence a column default draws on, or one a column owns with &lt;code&gt;OWNED BY&lt;/code&gt;, comes back bare: &lt;code&gt;bigint&lt;/code&gt;, starting at 1. Saleor's three such sequences, among them &lt;code&gt;order_order_number_seq&lt;/code&gt; on &lt;code&gt;order_order.number&lt;/code&gt;, match their originals because those were bare too.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The import summary counts what it skips: 12 statements for Mastodon (seven sequences &lt;code&gt;timestamp_id()&lt;/code&gt; reads, two views, the function and two indexes on the materialized view), all but the indexes named, and 1 for Saleor, a function no table calls. Saleor's four extensions and their comments are kept, not skipped; before this round they were 8 of 9 skipped statements.&lt;/p&gt;

&lt;p&gt;One gap was found only on the way to this post: Saleor's three owned sequences have no column default, nothing in the schema reads them, and the first version of the catalog diff did not list sequences, so they were missing from every rebuilt copy without a line of difference. Reading the skipped statements caught it. The diagram now keeps a sequence a column owns, and the catalog diff lists sequences.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this benchmark does not show
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Other databases.&lt;/strong&gt; Everything here is PostgreSQL. MySQL, &lt;a href="https://schemity.com/blog/sqlserver-round-trip-bitwarden-smartstore/" rel="noopener noreferrer"&gt;SQL Server&lt;/a&gt;, SQLite and &lt;a href="https://schemity.com/blog/snowflake-round-trip-tpcds/" rel="noopener noreferrer"&gt;Snowflake&lt;/a&gt; each get their own schema and their own post. Converting a diagram from PostgreSQL to one of them leaves identity kinds, &lt;code&gt;DEFERRABLE&lt;/code&gt; and partial and expression indexes out, and the converted script's notes name each one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data.&lt;/strong&gt; The originals are empty, and no rows were copied. A schema that matches is the precondition for moving data, not proof that a migration of a full database is safe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prisma, and other frameworks.&lt;/strong&gt; Rails and Django are covered; a schema from Prisma, Laravel or a hand-written monolith may use features neither of these uses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The live path before the fixes.&lt;/strong&gt; The "before" column covers Import SQL only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Triggers.&lt;/strong&gt; Neither schema has one, so nothing here tests them. A trigger is not a table either, and one left behind fails nothing: the rebuilt database simply behaves differently, which is the case this benchmark exists to catch. A schema that leans on triggers, such as an audit table or an &lt;code&gt;updated_at&lt;/code&gt; trigger, is the next one to test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schemas this size are not a stress test.&lt;/strong&gt; 118 and 148 tables are typical, not large; how Schemity handles a schema of a thousand tables is in &lt;a href="https://schemity.com/blog/database-diagram-1000-tables-memory-cpu/" rel="noopener noreferrer"&gt;a separate post&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to rerun it
&lt;/h2&gt;

&lt;p&gt;The method needs nothing from us. Create each schema with its own framework in its official Docker image, take &lt;code&gt;pg_dump --schema-only --no-owner --no-privileges&lt;/code&gt;, put it through Schemity on either path, export the SQL, and apply it to an empty database. Through the app, Save does that and runs the setup SQL first; by hand, View SQL's script already starts with it. Then list both catalogs with a query like this one and diff the output:&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;line&lt;/span&gt; &lt;span class="k"&gt;FROM&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;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'column %s.%s %s%s%s'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;format_type&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;atttypid&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;atttypmod&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
         &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_nullable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'NO'&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;' NOT NULL'&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;' DEFAULT '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;column_default&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
         &lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_identity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'YES'&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="s1"&gt;' IDENTITY '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;identity_generation&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="k"&gt;END&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;line&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
  &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_attribute&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;ON&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;attrelid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'public.%I'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&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;attname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;column_name&lt;/span&gt;
  &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tables&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;table_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'BASE TABLE'&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;table_schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
  &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'constraint %s %s %s'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;conrelid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;contype&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;pg_get_constraintdef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_constraint&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;connamespace&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regnamespace&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;contype&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'p'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'u'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'f'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'c'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'index %s %s'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;regexp_replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;indexdef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'^CREATE (UNIQUE )?INDEX &lt;/span&gt;&lt;span class="se"&gt;\S&lt;/span&gt;&lt;span class="s1"&gt;+ ON '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'CREATE &lt;/span&gt;&lt;span class="se"&gt;\1&lt;/span&gt;&lt;span class="s1"&gt;INDEX ON '&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_indexes&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
  &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'sequence %s %s start %s%s'&lt;/span&gt;&lt;span class="p"&gt;,&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;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;format_type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seqtypid&lt;/span&gt;&lt;span class="p"&gt;,&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;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seqstart&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;' owned by '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&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;attname&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_depend&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;
                   &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;refobjid&lt;/span&gt;
                   &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_attribute&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;ON&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;attrelid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&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;attnum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;refobjsubid&lt;/span&gt;
                   &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objid&lt;/span&gt; &lt;span class="o"&gt;=&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;oid&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deptype&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'a'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'i'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_sequence&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;seqrelid&lt;/span&gt; &lt;span class="o"&gt;=&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;oid&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;relnamespace&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regnamespace&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="k"&gt;ORDER&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For behaviour, write a few statements per rule you care about, run each inside &lt;code&gt;BEGIN&lt;/code&gt; and &lt;code&gt;ROLLBACK&lt;/code&gt; on the original and on the copy, and compare which ones PostgreSQL accepts and at which step it refuses the rest.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>rails</category>
      <category>django</category>
    </item>
    <item>
      <title>MySQL UNSIGNED Primary Keys: Signed or Unsigned, and How to Fix Error 3780 When They Mix</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Mon, 19 Oct 2026 04:34:51 +0000</pubDate>
      <link>https://dev.to/tbson87/mysql-unsigned-primary-keys-signed-or-unsigned-and-how-to-fix-error-3780-when-they-mix-3gen</link>
      <guid>https://dev.to/tbson87/mysql-unsigned-primary-keys-signed-or-unsigned-and-how-to-fix-error-3780-when-they-mix-3gen</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://schemity.com" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt;, a desktop ERD tool - this post is from our blog and uses it for the examples.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Use the sign your schema already uses, because MySQL refuses a foreign key whose sign differs from the key it references. Laravel and WordPress create &lt;code&gt;BIGINT UNSIGNED&lt;/code&gt; keys, while Rails and Django create signed &lt;code&gt;BIGINT&lt;/code&gt;. For a new schema with nothing deciding for you, signed &lt;code&gt;BIGINT&lt;/code&gt; is simpler, and changing the sign later means dropping every foreign key that references the key and copying the key's table and each referencing table.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A MySQL primary key can be signed or &lt;code&gt;UNSIGNED&lt;/code&gt;, and both cost the same storage: 4 bytes for &lt;code&gt;INT&lt;/code&gt;, 8 for &lt;code&gt;BIGINT&lt;/code&gt;. What decides the choice is not the range but the foreign keys. MySQL refuses any foreign key whose sign differs from the column it references, so the sign of the first key decides the sign of every key column that will ever point at it.&lt;/p&gt;

&lt;p&gt;That is why the question usually arrives as an error rather than a design discussion. A migration adds an &lt;code&gt;orders&lt;/code&gt; table to a Laravel database, declares &lt;code&gt;user_id&lt;/code&gt; as a plain &lt;code&gt;BIGINT&lt;/code&gt;, and MySQL answers with error 3780. Below is what each choice gets you, measured on MySQL 8.4.11 in a throwaway container, how to pick, and what changing your mind later costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What UNSIGNED changes in a MySQL integer column
&lt;/h2&gt;

&lt;p&gt;Only the range. The &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/integer-types.html" rel="noopener noreferrer"&gt;MySQL manual's integer table&lt;/a&gt; gives each type one storage size, signed or not:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Bytes&lt;/th&gt;
&lt;th&gt;Signed maximum&lt;/th&gt;
&lt;th&gt;
&lt;code&gt;UNSIGNED&lt;/code&gt; maximum&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;INT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;2,147,483,647&lt;/td&gt;
&lt;td&gt;4,294,967,295&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BIGINT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;9,223,372,036,854,775,807&lt;/td&gt;
&lt;td&gt;18,446,744,073,709,551,615&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On 8.4, inserting 2,147,483,648 into a signed &lt;code&gt;INT&lt;/code&gt; and -1 into an &lt;code&gt;INT UNSIGNED&lt;/code&gt; both failed with &lt;code&gt;ERROR 1264: Out of range value&lt;/code&gt;. The doubled range is real for &lt;code&gt;INT&lt;/code&gt;, where 2.1 billion is a number a busy events table can reach. For &lt;code&gt;BIGINT&lt;/code&gt; it is academic: inserting a million rows a second, a signed key lasts about 292,000 years.&lt;/p&gt;

&lt;p&gt;Two things &lt;code&gt;UNSIGNED&lt;/code&gt; is not. It is not deprecated on integers: MySQL 8.0.17 deprecated it for &lt;code&gt;DECIMAL&lt;/code&gt;, &lt;code&gt;FLOAT&lt;/code&gt; and &lt;code&gt;DOUBLE&lt;/code&gt;, and 8.4 still warns &lt;code&gt;UNSIGNED for decimal and floating point data types is deprecated&lt;/code&gt; on those, but not on &lt;code&gt;INT&lt;/code&gt; or &lt;code&gt;BIGINT&lt;/code&gt;. And the &lt;code&gt;(20)&lt;/code&gt; in &lt;code&gt;BIGINT(20) UNSIGNED&lt;/code&gt; is a display width, deprecated in the same release. 8.4 warns &lt;code&gt;Integer display width is deprecated&lt;/code&gt; and &lt;code&gt;SHOW CREATE TABLE&lt;/code&gt; drops it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which frameworks create signed and unsigned keys
&lt;/h2&gt;

&lt;p&gt;Most schemas did not choose. The framework that created the first table did:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Framework&lt;/th&gt;
&lt;th&gt;Primary key it creates on MySQL&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Laravel&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;BIGINT UNSIGNED&lt;/code&gt; from &lt;code&gt;$table-&amp;gt;id()&lt;/code&gt;, and &lt;code&gt;foreignId()&lt;/code&gt; matches it&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://laravel.com/docs/13.x/migrations" rel="noopener noreferrer"&gt;Laravel 13 migrations docs&lt;/a&gt;: &lt;code&gt;id&lt;/code&gt; is "an alias of the &lt;code&gt;bigIncrements&lt;/code&gt; method", which "creates an auto-incrementing &lt;code&gt;UNSIGNED BIGINT&lt;/code&gt;"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WordPress&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;bigint(20) unsigned&lt;/code&gt; on every core table: &lt;code&gt;ID&lt;/code&gt;, &lt;code&gt;post_author&lt;/code&gt;, &lt;code&gt;comment_ID&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/WordPress/WordPress/blob/master/wp-admin/includes/schema.php" rel="noopener noreferrer"&gt;wp-admin/includes/schema.php&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rails&lt;/td&gt;
&lt;td&gt;signed &lt;code&gt;bigint&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;primary_key: "bigint auto_increment PRIMARY KEY"&lt;/code&gt; in &lt;a href="https://github.com/rails/rails/blob/main/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb" rel="noopener noreferrer"&gt;the MySQL adapter&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Django&lt;/td&gt;
&lt;td&gt;signed &lt;code&gt;bigint&lt;/code&gt; for &lt;code&gt;BigAutoField&lt;/code&gt;, &lt;code&gt;integer&lt;/code&gt; for &lt;code&gt;AutoField&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;"BigAutoField": "bigint AUTO_INCREMENT"&lt;/code&gt; in &lt;a href="https://github.com/django/django/blob/main/django/db/backends/mysql/base.py" rel="noopener noreferrer"&gt;the MySQL backend&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Laravel's current helpers stay consistent with each other, but its default changed once. Apps created before Laravel 5.8 made &lt;code&gt;users.id&lt;/code&gt; with &lt;code&gt;$table-&amp;gt;increments('id')&lt;/code&gt;, which is &lt;code&gt;INT UNSIGNED&lt;/code&gt;, so a new &lt;code&gt;foreignId('user_id')&lt;/code&gt; pointing at it fails, and the column needs &lt;code&gt;$table-&amp;gt;unsignedInteger('user_id')&lt;/code&gt; instead. WordPress core declares no foreign keys at all, so there the error comes from a plugin or a hand-written table. Otherwise the problem appears where two conventions meet: a hand-written &lt;code&gt;$table-&amp;gt;bigInteger('user_id')&lt;/code&gt; (Laravel's &lt;code&gt;bigInteger&lt;/code&gt; is signed) next to &lt;code&gt;$table-&amp;gt;id()&lt;/code&gt;, a Laravel app added on top of a database a Rails or raw-SQL app created first, or a reporting table written by hand in a WordPress database.&lt;/p&gt;

&lt;p&gt;To see which convention a database already uses, count its integer primary keys by type:&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="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;COLUMN_TYPE&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;primary_keys&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;COLUMNS&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;KEY_COLUMN_USAGE&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TABLE_SCHEMA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TABLE_SCHEMA&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;TABLE_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;TABLE_NAME&lt;/span&gt;
 &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;COLUMN_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;COLUMN_NAME&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;CONSTRAINT_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PRIMARY'&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TABLE_SCHEMA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DATA_TYPE&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'tinyint'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'smallint'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'mediumint'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'int'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'bigint'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;COLUMN_TYPE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One row back means one convention. Two rows means the next foreign key has to check which table it points at.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should a MySQL primary key be UNSIGNED?
&lt;/h2&gt;

&lt;p&gt;Only when the schema around it already is. The sign is not a per-table preference, it is a convention the whole schema has to share, and the cheapest choice is almost always the one already in the database:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Choose&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Laravel or WordPress schema&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;BIGINT UNSIGNED&lt;/code&gt;, on keys and every foreign key column&lt;/td&gt;
&lt;td&gt;Every existing key is unsigned, and a signed column cannot reference one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rails or Django schema&lt;/td&gt;
&lt;td&gt;Signed &lt;code&gt;BIGINT&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Same rule, the other way round&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New schema, no framework deciding&lt;/td&gt;
&lt;td&gt;Signed &lt;code&gt;BIGINT&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The extra range is unreachable, and signed avoids the problems below&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A 4-byte &lt;code&gt;INT&lt;/code&gt; key that may pass 2.1 billion&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;BIGINT&lt;/code&gt;, unless key size really matters&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;INT UNSIGNED&lt;/code&gt; doubles the headroom once, &lt;code&gt;BIGINT&lt;/code&gt; multiplies it by four billion&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The case for signed in a new schema is everything outside the table. Subtraction is one: on 8.4, &lt;code&gt;SELECT a.id - b.id&lt;/code&gt; between two &lt;code&gt;BIGINT UNSIGNED&lt;/code&gt; ids, 1 minus 2, failed with &lt;code&gt;ERROR 1690: BIGINT UNSIGNED value is out of range&lt;/code&gt;, and &lt;code&gt;CAST(a.id - b.id AS SIGNED)&lt;/code&gt; failed the same way, because the subtraction runs first. Only &lt;code&gt;CAST(a.id AS SIGNED) - CAST(b.id AS SIGNED)&lt;/code&gt;, or the &lt;code&gt;NO_UNSIGNED_SUBTRACTION&lt;/code&gt; SQL mode, returned -1. Porting is another: PostgreSQL, SQL Server and SQLite have no unsigned 64-bit integer, so a &lt;code&gt;BIGINT UNSIGNED&lt;/code&gt; key either moves to &lt;code&gt;BIGINT&lt;/code&gt; with a theoretical upper half it can no longer hold, or to &lt;code&gt;NUMERIC&lt;/code&gt;. Java's &lt;code&gt;long&lt;/code&gt;, Go's &lt;code&gt;int64&lt;/code&gt; and PHP's &lt;code&gt;int&lt;/code&gt; are signed 64-bit as well, so a &lt;code&gt;BIGINT UNSIGNED&lt;/code&gt; value above 2^63-1 has no native type in them.&lt;/p&gt;

&lt;p&gt;Whichever you pick, the rule that is not optional is the same: a foreign key column gets exactly the type of the key it references, &lt;code&gt;UNSIGNED&lt;/code&gt; included.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does MySQL return error 3780 for a foreign key?
&lt;/h2&gt;

&lt;p&gt;Because the &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/create-table-foreign-keys.html" rel="noopener noreferrer"&gt;MySQL manual&lt;/a&gt; requires it: "The size and sign of fixed precision types such as INTEGER and DECIMAL must be the same." This is the Laravel case, reproduced on 8.4.11:&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;BIGINT&lt;/span&gt; &lt;span class="nb"&gt;UNSIGNED&lt;/span&gt; &lt;span class="n"&gt;AUTO_INCREMENT&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;255&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;posts&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;BIGINT&lt;/span&gt; &lt;span class="nb"&gt;UNSIGNED&lt;/span&gt; &lt;span class="n"&gt;AUTO_INCREMENT&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_id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;posts_user_id_foreign&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_id&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;id&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 plaintext"&gt;&lt;code&gt;ERROR 3780 (HY000): Referencing column 'user_id' and referenced column 'id' in foreign key constraint 'posts_user_id_foreign' are incompatible.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details from the same test session:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;SET foreign_key_checks = 0&lt;/code&gt; does not get it through.&lt;/strong&gt; The same &lt;code&gt;CREATE TABLE&lt;/code&gt; failed with the same error. The manual lists "creating a foreign key constraint where a column references a nonmatching column type" among the things disabled checks still refuse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same sign, different size fails too.&lt;/strong&gt; An &lt;code&gt;INT UNSIGNED&lt;/code&gt; column referencing &lt;code&gt;BIGINT UNSIGNED&lt;/code&gt; returned 3780 as well.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The direction does not matter.&lt;/strong&gt; A &lt;code&gt;BIGINT UNSIGNED&lt;/code&gt; column referencing a signed &lt;code&gt;BIGINT&lt;/code&gt; key failed the same way as the reverse. So did a Laravel &lt;code&gt;foreignId('account_id')&lt;/code&gt;, which differs in both size and sign, pointing at a legacy signed &lt;code&gt;INT&lt;/code&gt; key. The fix there is &lt;code&gt;$table-&amp;gt;integer('account_id')&lt;/code&gt; followed by &lt;code&gt;$table-&amp;gt;foreign('account_id')-&amp;gt;references('id')-&amp;gt;on('accounts')&lt;/code&gt;, matching the key rather than the framework's default.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The other cause of error 3780: string keys with different collations
&lt;/h3&gt;

&lt;p&gt;Integer keys are not the only ones. The same manual passage goes on: "For nonbinary (character) string columns, the character set and collation must be the same." A &lt;code&gt;CHAR(36)&lt;/code&gt; UUID key or a &lt;code&gt;country_code&lt;/code&gt; hits this whenever the key's table and the new table were created under different defaults, and the error text is identical, so it reads like a type problem.&lt;/p&gt;

&lt;p&gt;It usually comes from a table made years ago. A database created on MySQL 8.0 or later defaults to &lt;code&gt;utf8mb4_0900_ai_ci&lt;/code&gt;, the &lt;a href="https://dev.mysql.com/doc/refman/8.0/en/charset-server.html" rel="noopener noreferrer"&gt;8.0 server default&lt;/a&gt;. Older tables often carry &lt;code&gt;utf8mb3_general_ci&lt;/code&gt;, and Laravel's default config writes &lt;code&gt;utf8mb4_unicode_ci&lt;/code&gt; onto every table its migrations create. Any table made without a &lt;code&gt;COLLATE&lt;/code&gt; clause, by raw SQL, a GUI or another framework, gets the database default instead. On 8.4.11:&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;tenants&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;CHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;36&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="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;name&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;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;CHARSET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;utf8mb4&lt;/span&gt; &lt;span class="k"&gt;COLLATE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;utf8mb4_unicode_ci&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;projects&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;BIGINT&lt;/span&gt; &lt;span class="nb"&gt;UNSIGNED&lt;/span&gt; &lt;span class="n"&gt;AUTO_INCREMENT&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;tenant_id&lt;/span&gt; &lt;span class="nb"&gt;CHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;36&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;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;projects_tenant_id_foreign&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;tenant_id&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;tenants&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&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 plaintext"&gt;&lt;code&gt;ERROR 3780 (HY000): Referencing column 'tenant_id' and referenced column 'id' in foreign key constraint 'projects_tenant_id_foreign' are incompatible.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both columns are &lt;code&gt;utf8mb4&lt;/code&gt; and both are &lt;code&gt;CHAR(36)&lt;/code&gt;. Only the collation differs, and &lt;code&gt;foreign_key_checks = 0&lt;/code&gt; did not get it through either. Declaring the column as &lt;code&gt;tenant_id CHAR(36) COLLATE utf8mb4_unicode_ci NOT NULL&lt;/code&gt; did. To see which keys a new table will fail against before you write one, list the primary key columns whose collation differs from the database default:&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="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;TABLE_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;COLUMN_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;COLLATION_NAME&lt;/span&gt;&lt;span class="p"&gt;,&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;DEFAULT_COLLATION_NAME&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;new_tables_get&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;COLUMNS&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;KEY_COLUMN_USAGE&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TABLE_SCHEMA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TABLE_SCHEMA&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;TABLE_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;TABLE_NAME&lt;/span&gt;
 &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;COLUMN_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;COLUMN_NAME&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;CONSTRAINT_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'PRIMARY'&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SCHEMATA&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;SCHEMA_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TABLE_SCHEMA&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TABLE_SCHEMA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;COLLATION_NAME&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&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;DEFAULT_COLLATION_NAME&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every row is a key whose foreign key columns need an explicit &lt;code&gt;COLLATE&lt;/code&gt;. Integer keys never appear, because their &lt;code&gt;COLLATION_NAME&lt;/code&gt; is &lt;code&gt;NULL&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does it cost to change a key from UNSIGNED to signed?
&lt;/h2&gt;

&lt;p&gt;More than one &lt;code&gt;ALTER&lt;/code&gt;. With &lt;code&gt;orders.user_id&lt;/code&gt; referencing &lt;code&gt;users.id&lt;/code&gt;, both &lt;code&gt;BIGINT UNSIGNED&lt;/code&gt;, the obvious migration fails on its first statement, with or without foreign key checks:&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;users&lt;/span&gt; &lt;span class="k"&gt;MODIFY&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="n"&gt;AUTO_INCREMENT&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 plaintext"&gt;&lt;code&gt;ERROR 3780 (HY000): Referencing column 'user_id' and referenced column 'id' in foreign key constraint 'orders_user_id_foreign' are incompatible.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Changing the child column first fails the same way. The foreign key has to go, both columns change, and the key comes back:&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;orders&lt;/span&gt; &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;FOREIGN&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt; &lt;span class="n"&gt;orders_user_id_foreign&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;users&lt;/span&gt; &lt;span class="k"&gt;MODIFY&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="n"&gt;AUTO_INCREMENT&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;MODIFY&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="nb"&gt;BIGINT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="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_user_id_foreign&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_id&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;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On two tables of 1,000,000 rows each, that took 7.3 seconds, and every statement after the drop reported 1,000,000 rows affected:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Statement&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;What MySQL did&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DROP FOREIGN KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.01 s&lt;/td&gt;
&lt;td&gt;Metadata only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;MODIFY COLUMN&lt;/code&gt; on &lt;code&gt;users.id&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1.53 s&lt;/td&gt;
&lt;td&gt;Copied the table: a type change is &lt;code&gt;ALGORITHM=COPY&lt;/code&gt; only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;MODIFY COLUMN&lt;/code&gt; on &lt;code&gt;orders.user_id&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;2.49 s&lt;/td&gt;
&lt;td&gt;Copied the table&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ADD CONSTRAINT ... FOREIGN KEY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3.27 s&lt;/td&gt;
&lt;td&gt;Copied &lt;code&gt;orders&lt;/code&gt; again: with checks on, &lt;code&gt;ALGORITHM=INPLACE&lt;/code&gt; is refused with &lt;code&gt;Adding foreign keys needs foreign_key_checks=OFF&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The last step is the one you can make cheaper. With &lt;code&gt;foreign_key_checks = 0&lt;/code&gt;, the same &lt;code&gt;ADD CONSTRAINT&lt;/code&gt; on a 1,000,000-row table affected 0 rows and took 0.87 s, because MySQL skips checking the existing rows. That is safe here, where only the type changed and no value did, and unsafe anywhere rows might not match.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-online-ddl-operations.html" rel="noopener noreferrer"&gt;online DDL table&lt;/a&gt; lists a column type change as "Permits Concurrent DML: No", so writes to each table wait while it is copied. Multiply by every table that references &lt;code&gt;users&lt;/code&gt; and the cost of switching is clear: decide the sign once, at the first table, because changing it later is a schema migration across the whole graph of foreign keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Schemity handles UNSIGNED keys
&lt;/h2&gt;

&lt;p&gt;Schemity is database design software that reads your live database, shows the impact of every schema change before it runs, and keeps the diagram as a file in Git. When you &lt;a href="https://schemity.com/doc/connect-mysql/" rel="noopener noreferrer"&gt;connect to MySQL&lt;/a&gt;, it reads each column's sign with its type, and the canvas shows an unsigned column as &lt;code&gt;uBIGINT&lt;/code&gt;, so a schema's convention, and any table that breaks it, is visible at a glance across the diagram.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxoltkxjc23otv4s12owj.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fxoltkxjc23otv4s12owj.webp" alt="Schemity's canvas reading the users, orders and accounts tables from MySQL 8.4: users.id, orders.id and orders.user_id are uBIGINT, the u prefix marking BIGINT UNSIGNED, while accounts.id and orders.account_id are plain INTEGER; orders.total is DECIMAL(10,2) with a 0.00 default, users.created_at is a nullable TIMESTAMP marked N, and orders shows idx: 2 for the two foreign key indexes" width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;To design the next one, the &lt;a href="https://schemity.com/doc/tables-and-fields/" rel="noopener noreferrer"&gt;field editor&lt;/a&gt; offers an Unsigned checkbox on MySQL integer types. Drawing a &lt;a href="https://schemity.com/doc/relationships/" rel="noopener noreferrer"&gt;relationship&lt;/a&gt; copies the key's full type into the new foreign key column, sign included, so a new table pointing at a Laravel &lt;code&gt;users.id&lt;/code&gt; gets &lt;code&gt;user_id BIGINT UNSIGNED&lt;/code&gt; and its migration applies instead of failing with 3780. A string key's character set and collation are copied the same way, so a column pointing at &lt;code&gt;tenants.id&lt;/code&gt; is created with &lt;code&gt;COLLATE utf8mb4_unicode_ci&lt;/code&gt; whatever the database default is. Ticking or clearing Unsigned on a key changes every foreign key column that references it in the same edit, one undo step.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fql9lx5aaqfjmhoycq1tm.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fql9lx5aaqfjmhoycq1tm.webp" alt="Schemity's canvas with a new posts table drafted beside the users, orders and accounts tables read from MySQL, drawn with a dashed border because it is not yet in the database: the relation from users gave posts.user_id the type uBIGINT, matching users.id, and posts.id was set to uBIGINT with the Unsigned checkbox so the new table follows the schema's unsigned convention" width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz4bqhaev8kg5468djbup.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fz4bqhaev8kg5468djbup.webp" alt="Schemity's Findings drawer previewing the new posts table: the planned migration creates posts with id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY and user_id BIGINT UNSIGNED NOT NULL, with the constraint posts_user_id_fkey FOREIGN KEY (user_id) REFERENCES users (id), so the column matches users.id and MySQL accepts the key; Planned changes lists Table posts created, and the only lint finding is the unrelated users.created_at" width="800" height="547"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A mismatch that already exists, or one you type yourself, is caught by &lt;a href="https://schemity.com/doc/schema-lint/" rel="noopener noreferrer"&gt;lint&lt;/a&gt;. Its fk-type-mismatch rule reports "Foreign key type does not match the column it references" on the foreign key's field row, with the detail that the two columns differ in &lt;code&gt;UNSIGNED&lt;/code&gt; and that MySQL refuses a foreign key whose column and key differ in sign. On MySQL it also names both collations when a string key and its foreign key column differ in character set or collation.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4b402p368rja3ag7kd5j.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4b402p368rja3ag7kd5j.webp" alt="Schemity's Lint drawer beside the canvas after Unsigned was cleared on posts.user_id: under Fails at runtime, the finding on posts.user_id reads Foreign key type does not match the column it references, with the detail posts.user_id and users.id differ in UNSIGNED. MySQL refuses a foreign key whose column and key differ in sign; a red bar in the margin marks the posts.user_id row, now BIGINT against users.id uBIGINT, and a second finding under Convention notes that users.created_at is nullable with no default" width="800" height="482"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Changing the sign of a key that is already referenced plans the drop, modify, re-add sequence from the section above, and &lt;a href="https://schemity.com/doc/impact-analysis/" rel="noopener noreferrer"&gt;impact analysis&lt;/a&gt; reports each &lt;code&gt;MODIFY&lt;/code&gt; before it runs as "Rewrites users to change id" with the row count, and a sign change that can lose values, such as &lt;code&gt;BIGINT UNSIGNED&lt;/code&gt; to &lt;code&gt;BIGINT&lt;/code&gt;, as "values may not survive the cast". The foreign key it adds back is listed too, as "Checking the new key holds back writes to orders", since that was the slowest step in the test above. If you later convert the diagram's SQL to PostgreSQL, SQL Server or SQLite, unsigned integers are widened one size so every value still fits, and a &lt;code&gt;BIGINT UNSIGNED&lt;/code&gt; stays a signed 64-bit integer (&lt;code&gt;BIGINT&lt;/code&gt;, or &lt;code&gt;INTEGER&lt;/code&gt; on SQLite) with a note that values above 2^63-1 would not.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj0po3hpdpmxz8xd5pdr0.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj0po3hpdpmxz8xd5pdr0.webp" alt="Schemity's Findings drawer after Unsigned was cleared on users.id, with orders.user_id following it to BIGINT on the canvas: the planned migration drops the foreign key orders_user_id_foreign, modifies orders.user_id and users.id to BIGINT with foreign key checks off, then adds the constraint back with checks on; Impact lists both columns as values may not survive the cast, Rewrites orders to change user_id and Rewrites users to change id at about 199.6K rows each, writes held back on both tables while they are copied and while the new key is checked, users.id reaching orders through orders_user_id_foreign, and Drops and recreates a foreign key on orders" width="800" height="673"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Signed or unsigned MySQL keys, in one list
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Existing schema: use whatever sign its primary keys already have. The &lt;code&gt;information_schema&lt;/code&gt; query above tells you.&lt;/li&gt;
&lt;li&gt;Laravel or WordPress: &lt;code&gt;BIGINT UNSIGNED&lt;/code&gt; everywhere, including hand-written foreign key columns.&lt;/li&gt;
&lt;li&gt;Rails, Django, or a new schema with no framework: signed &lt;code&gt;BIGINT&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Foreign key column: exactly the key's type, sign and size included. Error 3780 means it is not.&lt;/li&gt;
&lt;li&gt;Changing the sign later: drop the foreign keys, modify both sides, add the keys back, and expect each table to be copied.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same choice on PostgreSQL, where there is no unsigned option but the key can be a UUID, is in &lt;a href="https://schemity.com/blog/postgres-uuid-vs-bigint-primary-key/" rel="noopener noreferrer"&gt;UUID vs bigint primary keys in Postgres&lt;/a&gt;. Naming the constraints those foreign keys create, so &lt;code&gt;orders_user_id_foreign&lt;/code&gt; still means something in five years, is in &lt;a href="https://schemity.com/blog/postgres-constraint-naming-convention/" rel="noopener noreferrer"&gt;the PostgreSQL constraint naming convention&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>mysql</category>
      <category>database</category>
      <category>laravel</category>
      <category>sql</category>
    </item>
    <item>
      <title>Foreign Keys Between Modules in a Modular Monolith: Keep, Restrict, or Drop?</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Mon, 19 Oct 2026 04:34:16 +0000</pubDate>
      <link>https://dev.to/tbson87/foreign-keys-between-modules-in-a-modular-monolith-keep-restrict-or-drop-19d6</link>
      <guid>https://dev.to/tbson87/foreign-keys-between-modules-in-a-modular-monolith-keep-restrict-or-drop-19d6</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://schemity.com" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt;, a desktop ERD tool - this post is from our blog and uses it for the examples.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Keep a foreign key between two modules while they share one database and ship together: it costs an index and a row lock, and the coupling it creates is the coupling your code already has. Change its delete action to &lt;code&gt;RESTRICT&lt;/code&gt; so no module deletes another module's rows, and drop it only when a module moves to its own database or needs to rebuild its tables alone, keeping the column documented and checked for orphans.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A foreign key from one module's table to another's is worth keeping while both modules live in one database and ship as one application. It costs an index and a row lock per write, it refuses orphan rows, and on the day a module leaves, dropping it is one &lt;code&gt;ALTER TABLE&lt;/code&gt;. What you should not keep is a cross-module &lt;code&gt;ON DELETE CASCADE&lt;/code&gt;, and what you cannot afford is not knowing where those keys are.&lt;/p&gt;

&lt;p&gt;The common advice runs the other way. Milan Jovanović's &lt;a href="https://milanjovanovic.tech/blog/modular-monolith-data-isolation" rel="noopener noreferrer"&gt;modular monolith data isolation&lt;/a&gt; guide states that "Joins are only allowed between tables of the same module" and that "Removing the foreign keys when extracting tables is not a problem." Both are true. Neither is an argument for removing the keys today, years before any extraction, and giving up the one integrity check that no module can forget to call. The question worth answering is narrower: which cross-module keys to keep, which delete action they should have, and when one should go.&lt;/p&gt;

&lt;p&gt;Every statement below was run on PostgreSQL 18.3 in a throwaway container. The example is a Django-style monolith in one schema, where the table name prefix is the module: &lt;code&gt;customers_customer&lt;/code&gt;, &lt;code&gt;ordering_order&lt;/code&gt;, &lt;code&gt;billing_invoice&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Foreign keys between modules in a modular monolith: what do they cost?
&lt;/h2&gt;

&lt;p&gt;Here is the schema most monoliths end up with:&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;customers_customer&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;bigint&lt;/span&gt; &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;IDENTITY&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;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;ordering_order&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;bigint&lt;/span&gt; &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;IDENTITY&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;customer_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;customers_customer&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;total&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;12&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;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;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;billing_invoice&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;bigint&lt;/span&gt; &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;IDENTITY&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;order_id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;ordering_order&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;amount&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;12&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;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two keys cross a boundary, and each one ties the modules together in five concrete ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A delete in one module reaches into another.&lt;/strong&gt; Deleting a customer whose orders were never invoiced silently deletes those orders, which belong to the ordering module. Deleting a customer whose order was invoiced fails, and the error names a table the customers module has never heard of: &lt;code&gt;ERROR: update or delete on table "ordering_order" violates foreign key constraint "billing_invoice_order_id_fkey" on table "billing_invoice"&lt;/code&gt;. The customers module's delete was stopped by billing, two modules away.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A module cannot reset or rebuild its own tables.&lt;/strong&gt; &lt;code&gt;TRUNCATE customers_customer&lt;/code&gt; fails with &lt;code&gt;cannot truncate a table referenced in a foreign key constraint&lt;/code&gt;, and &lt;code&gt;DROP TABLE customers_customer&lt;/code&gt; fails with &lt;code&gt;cannot drop table customers_customer because other objects depend on it&lt;/code&gt;. A migration that rebuilds one of them needs the other module's cooperation. Tests have an easier way out: the error's own hint is to truncate both tables in one statement, or a test can roll back its transaction instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every write reaches into the other module.&lt;/strong&gt; Inserting an order takes a &lt;code&gt;FOR KEY SHARE&lt;/code&gt; lock on the customer row it points at, which &lt;code&gt;pgrowlocks&lt;/code&gt; shows while the transaction is open, and deleting a customer looks up its orders by &lt;code&gt;customer_id&lt;/code&gt;. PostgreSQL does not index a foreign key column for you, so without &lt;code&gt;CREATE INDEX ON ordering_order (customer_id)&lt;/code&gt; that lookup scans the whole orders table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schema changes lock the other module's table.&lt;/strong&gt; The &lt;a href="https://www.postgresql.org/docs/current/sql-altertable.html" rel="noopener noreferrer"&gt;&lt;code&gt;ALTER TABLE&lt;/code&gt; documentation&lt;/a&gt; says &lt;code&gt;ADD FOREIGN KEY&lt;/code&gt; "also acquires a &lt;code&gt;SHARE ROW EXCLUSIVE&lt;/code&gt; lock on the referenced table". Dropping one is heavier: in the same container, &lt;code&gt;ALTER TABLE ... DROP CONSTRAINT&lt;/code&gt; on a cross-module key held &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; on both the child table and the referenced table until it committed. On a busy table, run it with &lt;code&gt;SET lock_timeout&lt;/code&gt; so it gives up instead of queueing every other query behind it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It cannot survive a move to another database.&lt;/strong&gt; PostgreSQL has no cross-database foreign keys: &lt;code&gt;REFERENCES orders_db.public.ordering_order (id)&lt;/code&gt; is refused with &lt;code&gt;cross-database references are not implemented&lt;/code&gt;. The key has to be dropped on the day the module moves out, whatever you decide now.&lt;/p&gt;

&lt;p&gt;None of these is a reason to drop a key in a monolith that is staying a monolith. They are the reasons to choose its delete action deliberately and to know exactly which keys you have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which cross-module foreign keys to keep, restrict, or drop
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Modules share one database and deploy together&lt;/td&gt;
&lt;td&gt;Keep the key, index its column&lt;/td&gt;
&lt;td&gt;Integrity costs an index and a row lock, and dropping it later is one statement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The key is &lt;code&gt;ON DELETE CASCADE&lt;/code&gt; across a boundary&lt;/td&gt;
&lt;td&gt;Keep it, change the action to &lt;code&gt;RESTRICT&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No module should delete another module's rows as a side effect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The module is moving to its own database&lt;/td&gt;
&lt;td&gt;Drop it before the move&lt;/td&gt;
&lt;td&gt;PostgreSQL cannot enforce it across databases&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The referenced module must rebuild its tables on its own in production&lt;/td&gt;
&lt;td&gt;Drop it&lt;/td&gt;
&lt;td&gt;Otherwise every rebuild needs the other module&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The referenced rows are archived or deleted on their own schedule&lt;/td&gt;
&lt;td&gt;Drop it, or copy the values you need&lt;/td&gt;
&lt;td&gt;The reference is history, not a live link&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Two modules reference each other&lt;/td&gt;
&lt;td&gt;Remove one direction first&lt;/td&gt;
&lt;td&gt;A loop means extracting either module means changing the other too&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;RESTRICT&lt;/code&gt; row is the one most schemas need today. A module that wants to delete a customer should be told that orders still point at it, and decide what that means for its own data: deactivate the customer, or ask the ordering module to deal with its orders first. The change is one statement, and afterwards the delete fails with &lt;code&gt;violates RESTRICT setting of foreign key constraint "ordering_order_customer_id_fkey"&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;ordering_order&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;ordering_order_customer_id_fkey&lt;/span&gt;&lt;span class="p"&gt;,&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;ordering_order_customer_id_fkey&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;customer_id&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;customers_customer&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;RESTRICT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Picking the action for a key inside a module is a different question, answered in &lt;a href="https://schemity.com/blog/postgres-on-delete-set-null-vs-cascade/" rel="noopener noreferrer"&gt;ON DELETE SET NULL vs CASCADE vs RESTRICT&lt;/a&gt;. Across a module boundary the answer is almost always &lt;code&gt;RESTRICT&lt;/code&gt; or &lt;code&gt;NO ACTION&lt;/code&gt;, because the child row belongs to someone else. The two refuse the same deletes; &lt;code&gt;NO ACTION&lt;/code&gt;, the default and what &lt;code&gt;billing_invoice&lt;/code&gt; already has, differs only in that its check can be deferred to the end of the transaction.&lt;/p&gt;

&lt;p&gt;A key also tends to point at a module's identity table rather than at its internals. &lt;code&gt;ordering_order.customer_id&lt;/code&gt; referencing &lt;code&gt;customers_customer&lt;/code&gt; is borrowing one concept, the customer, which is the shape a boundary can live with. A key from billing into a customers module's &lt;code&gt;customers_address&lt;/code&gt; table is reaching into an implementation detail, and it is the first one to question.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to list every foreign key that crosses a module boundary
&lt;/h2&gt;

&lt;p&gt;The catalogue already knows. When modules share a schema and the table prefix is the module, compare the prefix of the child table with the prefix of the parent:&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="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conrelid&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;child_table&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confrelid&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;parent_table&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confdeltype&lt;/span&gt;         &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;on_delete&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_constraint&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;conrelid&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;confrelid&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;contype&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'f'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;split_part&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'_'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;split_part&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pa&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'_'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the example it returns the two keys, with &lt;code&gt;on_delete&lt;/code&gt; reading &lt;code&gt;c&lt;/code&gt; for &lt;code&gt;ordering_order_customer_id_fkey&lt;/code&gt; and &lt;code&gt;a&lt;/code&gt; for &lt;code&gt;billing_invoice_order_id_fkey&lt;/code&gt;. The &lt;code&gt;pg_constraint&lt;/code&gt; documentation defines the codes: &lt;code&gt;a&lt;/code&gt; is no action, &lt;code&gt;r&lt;/code&gt; restrict, &lt;code&gt;c&lt;/code&gt; cascade, &lt;code&gt;n&lt;/code&gt; set null, &lt;code&gt;d&lt;/code&gt; set default, so every &lt;code&gt;c&lt;/code&gt; in that column is a cascade across a boundary. The prefix test assumes module names contain no underscore; a Django app called &lt;code&gt;user_profiles&lt;/code&gt; needs a list of module prefixes instead. When each module has its own schema, replace the last condition with &lt;code&gt;ch.relnamespace &amp;lt;&amp;gt; pa.relnamespace&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The query has two blind spots. It only sees declared keys, so a &lt;code&gt;customer_id&lt;/code&gt; column that was never given a constraint does not appear, though it is just as much a dependency; and it lists edges, not loops. Add &lt;code&gt;last_order_id bigint REFERENCES ordering_order (id)&lt;/code&gt; to &lt;code&gt;customers_customer&lt;/code&gt; and the result gains a third row, but nothing in it says that customers and ordering now depend on each other. Finding a loop through three modules from a flat list of rows is a graph walk you do by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you drop one, what replaces it?
&lt;/h2&gt;

&lt;p&gt;The column stays. What leaves is the guarantee, and three things should take its place.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An orphan check.&lt;/strong&gt; Without the key, nothing stops &lt;code&gt;billing_invoice&lt;/code&gt; from holding an &lt;code&gt;order_id&lt;/code&gt; of &lt;code&gt;999&lt;/code&gt; that no order has. A scheduled query finds 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;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;billing_invoice&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;ordering_order&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&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;A rule in the owning module.&lt;/strong&gt; If ordering may now hard-delete orders, billing has to learn about it, usually through an event, and decide whether an invoice for a deleted order is an error or a record to keep.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Documentation that the dependency still exists.&lt;/strong&gt; A dropped key leaves a column that looks like any other &lt;code&gt;bigint&lt;/code&gt;. The next engineer, and the next AI agent, reads the schema and sees no relationship at all, which is how &lt;a href="https://schemity.com/blog/should-you-use-foreign-key-constraints/" rel="noopener noreferrer"&gt;skipping foreign keys&lt;/a&gt; turns into a schema nobody can read.&lt;/p&gt;

&lt;p&gt;Going back is possible once the orphan check returns no rows, since any orphan makes validation fail. &lt;code&gt;ADD CONSTRAINT ... FOREIGN KEY ... NOT VALID&lt;/code&gt; commits without scanning the table, though it still takes &lt;code&gt;SHARE ROW EXCLUSIVE&lt;/code&gt; on both tables for that moment, and a later &lt;code&gt;VALIDATE CONSTRAINT&lt;/code&gt; checks the existing rows; the &lt;code&gt;ALTER TABLE&lt;/code&gt; documentation notes that validation takes only a &lt;code&gt;SHARE UPDATE EXCLUSIVE&lt;/code&gt; lock on the table, plus a &lt;code&gt;ROW SHARE&lt;/code&gt; lock on the referenced one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seeing module dependencies as a map instead of a query result
&lt;/h2&gt;

&lt;p&gt;Schemity is database design software that reads your live database, shows the impact of every schema change before it runs, and keeps the diagram as a file in Git. For this decision its job is to help you understand your schema at the scale of modules: connect it to the database, &lt;a href="https://schemity.com/blog/reverse-engineer-legacy-database-group-by-domain/" rel="noopener noreferrer"&gt;group the tables into one context view per module&lt;/a&gt;, and open the Context Map. Schemity reads one PostgreSQL schema per connection, so this works directly on the single-schema, prefix-per-module layout above; a schema-per-module database is one diagram per schema, and the map cannot join them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fexv1pmvigep7oebefpxb.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fexv1pmvigep7oebefpxb.webp" alt="The seven example tables on Schemity's main diagram, grouped into customers, ordering, billing and shipping legends, with declared foreign keys drawn solid and the undeclared order_id references from billing_refund and shipping_shipment drawn dashed" width="800" height="635"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A context view is a saved view of a subset of the schema's entities, and the &lt;a href="https://schemity.com/doc/context-map/" rel="noopener noreferrer"&gt;Context Map&lt;/a&gt; is the view above it, where each context view becomes one box and each arrow is counted from the relations that cross between two of them. The &lt;code&gt;pg_constraint&lt;/code&gt; query becomes a picture, with four things it could not show:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Declared and undeclared dependencies, side by side.&lt;/strong&gt; An arrow is solid when every relation behind it is a declared foreign key, dashed when every one is a &lt;a href="https://schemity.com/doc/virtual-relations/" rel="noopener noreferrer"&gt;virtual relation&lt;/a&gt;, and carries a ringed count badge when the two mix. When an arrow has any virtual relations, the dependency drawer behind it says how many are declared and how many are virtual, and tags each virtual row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loops.&lt;/strong&gt; A &lt;strong&gt;Cycles&lt;/strong&gt; button lists the dependency loops between context views, shortest first and up to 50, including loops through three or more modules where no single arrow looks wrong. Picking one selects its modules and lights only the loop's arrows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Direction for one module at a time.&lt;/strong&gt; Selecting a module highlights what it depends on, dims what depends on it, and hides every other arrow; Shift+click (Cmd+click on macOS) adds more modules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The boundary from inside a module.&lt;/strong&gt; In a context view, the boundary indicator on an entity lists what crosses it: the keys it holds pointing out, and the entities outside that reference it. Picking a row lands on that entity in the main diagram.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffo314stwt8143jskrprz.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffo314stwt8143jskrprz.webp" alt="The Context Map for the four modules: billing to ordering is a solid arrow with a dashed ring around its count of 2, shipping to ordering is a dashed arrow, ordering to customers and customers to billing are solid, and the Cycles button reads 1" width="800" height="635"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F987ue6yiud5cnhss5c3h.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F987ue6yiud5cnhss5c3h.webp" alt="The loop picked from the Cycles list: billing, ordering and customers selected with the three arrows between them at full strength, while the dashed shipping to ordering arrow is dimmed" width="800" height="635"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That last view is where the drop decision gets made. When a key comes out of the database, draw it back as a virtual relation on the column that already carries it, and the dependency stays on the diagram and on the map, dashed: the table above told you to keep documenting it, and this is the documentation. An AI agent sees the same split over the &lt;a href="https://schemity.com/doc/ai-assisted-design/" rel="noopener noreferrer"&gt;database MCP server&lt;/a&gt;, where &lt;code&gt;get_context_views&lt;/code&gt; returns each dependency with its relations, which of them are virtual, and the cycles.&lt;/p&gt;

&lt;p&gt;The arrows count a dependency whether or not the database enforces it, because architecturally it is the same dependency. A dashed arrow is not a smaller coupling than a solid one; it is the same coupling with no check behind it. Each arrow's relations, the keys you would have to drop to separate two modules, are one click away, and the &lt;a href="https://schemity.com/blog/your-ddd-context-map-is-already-in-your-foreign-keys/" rel="noopener noreferrer"&gt;DDD context map you get from your foreign keys&lt;/a&gt; is the same picture read as an architecture rather than a list.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4ybyjj4ltjgisajqju9w.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4ybyjj4ltjgisajqju9w.webp" alt="The dependency drawer for the billing to ordering arrow, reading 1 declared, 1 virtual: billing_invoice.order_id to ordering_order with constraint billing_invoice_order_id_fkey, and billing_refund.order_id to ordering_order tagged Virtual" width="800" height="635"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The decision itself is still yours. Keep the key while the modules share a database, give it &lt;code&gt;RESTRICT&lt;/code&gt; instead of &lt;code&gt;CASCADE&lt;/code&gt;, and drop it when a module leaves or needs its tables to itself, with the dependency still drawn where everyone can see it. A loop between two tables inside one module is a different problem, covered in &lt;a href="https://schemity.com/blog/circular-foreign-keys-first-row-cannot-be-inserted/" rel="noopener noreferrer"&gt;circular foreign keys and the first row&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>architecture</category>
      <category>database</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>PostgreSQL: Changing an Integer Column to Boolean - In Place or a New Column, and the Four Errors on the Way</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Mon, 19 Oct 2026 04:33:37 +0000</pubDate>
      <link>https://dev.to/tbson87/postgresql-changing-an-integer-column-to-boolean-in-place-or-a-new-column-and-the-four-errors-11ij</link>
      <guid>https://dev.to/tbson87/postgresql-changing-an-integer-column-to-boolean-in-place-or-a-new-column-and-the-four-errors-11ij</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://schemity.com" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt;, a desktop ERD tool - this post is from our blog and uses it for the examples.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Change it in place with &lt;code&gt;ALTER COLUMN ... TYPE boolean USING is_active &amp;lt;&amp;gt; 0&lt;/code&gt;, after dropping the integer default and anything that compares the column with a number: a &lt;code&gt;CHECK&lt;/code&gt;, a partial index, a view. The in-place change rewrites the table under a lock that blocks reads, and any query that still binds the integer &lt;code&gt;1&lt;/code&gt; fails the moment it commits. On a large or busy table, add a new boolean column, keep it in sync while you backfill it, and switch over to it in a transaction that takes milliseconds.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To change an integer flag to boolean in PostgreSQL, run &lt;code&gt;ALTER TABLE users ALTER COLUMN is_active TYPE boolean USING is_active &amp;lt;&amp;gt; 0&lt;/code&gt;, but only after removing what ties the column to integers, such as its default, a &lt;code&gt;CHECK (is_active IN (0, 1))&lt;/code&gt;, or a partial index or view with &lt;code&gt;WHERE is_active = 1&lt;/code&gt;. Each one stops the statement with its own error, and the &lt;code&gt;CHECK&lt;/code&gt; and the partial index fail with the same error, which names neither of them.&lt;/p&gt;

&lt;p&gt;That covers the statement. The decision is whether to run it in place at all. The type change rewrites the whole table under an &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; lock, and the moment it commits, a query that still sends the integer &lt;code&gt;1&lt;/code&gt; fails. On a table with millions of rows, or an application deployed separately from its migrations, adding a new boolean column and swapping it in is the safer route. Everything below was run on PostgreSQL 18.3 in a throwaway container.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why integer flags end up needing this migration
&lt;/h2&gt;

&lt;p&gt;Integer flags usually come from somewhere else: a schema ported from MySQL, where &lt;code&gt;BOOLEAN&lt;/code&gt; is an alias for &lt;code&gt;TINYINT(1)&lt;/code&gt;, a Rails app that started on MySQL, or a column someone added as &lt;code&gt;smallint&lt;/code&gt; years ago "in case we need a third state". It works, and it stays that way until someone wants the column to read like what it is. Here is the table used in every example:&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;bigint&lt;/span&gt; &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;IDENTITY&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;text&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;is_active&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;1&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;is_active&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;users_active_email_idx&lt;/span&gt; &lt;span class="k"&gt;ON&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="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;is_active&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;active_users&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;is_active&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How do I change a column from integer to boolean in Postgres?
&lt;/h2&gt;

&lt;p&gt;Try the obvious statement and PostgreSQL refuses four times, once for the missing cast and then once per object in the way:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Statement&lt;/th&gt;
&lt;th&gt;Error&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ALTER COLUMN is_active TYPE boolean&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;column "is_active" cannot be cast automatically to type boolean&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;... TYPE boolean USING is_active::boolean&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;default for column "is_active" cannot be cast automatically to type boolean&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The same, after &lt;code&gt;DROP DEFAULT&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cannot alter type of a column used by a view or rule&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The same, after &lt;code&gt;DROP VIEW active_users&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;operator does not exist: boolean = integer&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first error is the missing &lt;code&gt;USING&lt;/code&gt; clause: there is no automatic conversion from &lt;code&gt;integer&lt;/code&gt; to &lt;code&gt;boolean&lt;/code&gt;, so you have to say how. The second is the one that surprises people, because the &lt;code&gt;USING&lt;/code&gt; clause is right. PostgreSQL converts the column's default separately and does not apply your &lt;code&gt;USING&lt;/code&gt; expression to it, so &lt;code&gt;DEFAULT 1&lt;/code&gt; has no way to become a boolean. The third is the view, covered in detail in &lt;a href="https://schemity.com/blog/postgres-alter-column-type-used-by-view/" rel="noopener noreferrer"&gt;changing a column type used by a view&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The fourth comes from both the &lt;code&gt;CHECK&lt;/code&gt; constraint and the partial index. PostgreSQL rebuilds each of them against the new type, and &lt;code&gt;is_active IN (0, 1)&lt;/code&gt; and &lt;code&gt;WHERE is_active = 1&lt;/code&gt; now compare a boolean with an integer. The error does not name the constraint or the index, and dropping only the &lt;code&gt;CHECK&lt;/code&gt; produces the same message from the index.&lt;/p&gt;

&lt;p&gt;The migration that works drops every one of them, changes the type, and puts back boolean versions:&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;DROP&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;active_users&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;INDEX&lt;/span&gt; &lt;span class="n"&gt;users_active_email_idx&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;users&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;users_is_active_check&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;users&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;is_active&lt;/span&gt; &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&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;users&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;is_active&lt;/span&gt; &lt;span class="k"&gt;TYPE&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;is_active&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&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;is_active&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;users_active_email_idx&lt;/span&gt; &lt;span class="k"&gt;ON&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="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;is_active&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;active_users&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;is_active&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 &lt;code&gt;CHECK&lt;/code&gt; does not come back: a boolean column already holds only two values, which is the reason for the migration. A view also loses its grants when dropped, so restore those after recreating it.&lt;/p&gt;

&lt;h2&gt;
  
  
  USING is_active::boolean or is_active &amp;lt;&amp;gt; 0?
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;is_active &amp;lt;&amp;gt; 0&lt;/code&gt;. PostgreSQL has a cast from &lt;code&gt;integer&lt;/code&gt; to &lt;code&gt;boolean&lt;/code&gt;, but none from &lt;code&gt;smallint&lt;/code&gt; or &lt;code&gt;bigint&lt;/code&gt;, so a &lt;code&gt;smallint&lt;/code&gt; column such as &lt;code&gt;is_admin&lt;/code&gt; cannot use the cast form:&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;smallint&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- ERROR:  cannot cast type smallint to boolean&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So &lt;code&gt;USING is_admin::boolean&lt;/code&gt; works on an &lt;code&gt;integer&lt;/code&gt; column and fails on a &lt;code&gt;smallint&lt;/code&gt; one. Comparing with zero works on every width, and it gives the same answer as the &lt;code&gt;integer&lt;/code&gt; cast: &lt;code&gt;0&lt;/code&gt; becomes false and any other value, including &lt;code&gt;2&lt;/code&gt; and &lt;code&gt;-1&lt;/code&gt;, becomes true. If your data has values other than &lt;code&gt;0&lt;/code&gt; and &lt;code&gt;1&lt;/code&gt;, decide what they mean before the migration, because after it they are indistinguishable from &lt;code&gt;1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;ORMs write the cast form. Django's PostgreSQL backend appends &lt;code&gt;USING "is_active"::boolean&lt;/code&gt; whenever a field's database type changes, so turning an &lt;code&gt;IntegerField&lt;/code&gt; into a &lt;code&gt;BooleanField&lt;/code&gt; works, and turning a &lt;code&gt;SmallIntegerField&lt;/code&gt; into one fails with the error above; write that migration as &lt;code&gt;RunSQL&lt;/code&gt;. Rails' &lt;code&gt;change_column :users, :is_active, :boolean&lt;/code&gt; emits no &lt;code&gt;USING&lt;/code&gt; at all unless you pass &lt;code&gt;using: "is_active &amp;lt;&amp;gt; 0"&lt;/code&gt;, or &lt;code&gt;cast_as: :boolean&lt;/code&gt;, which writes &lt;code&gt;USING CAST(... AS boolean)&lt;/code&gt; and has the same &lt;code&gt;smallint&lt;/code&gt; problem. Passing &lt;code&gt;default: true&lt;/code&gt; in the same call does not help: Rails adds &lt;code&gt;SET DEFAULT true&lt;/code&gt; to the same &lt;code&gt;ALTER TABLE&lt;/code&gt;, and PostgreSQL 18.3 still failed on the old default. &lt;code&gt;default: nil&lt;/code&gt; does work: the &lt;code&gt;DROP DEFAULT&lt;/code&gt; it adds to the same statement runs before the type change, and &lt;code&gt;change_column_default&lt;/code&gt; can set &lt;code&gt;true&lt;/code&gt; afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to find everything that depends on the column
&lt;/h2&gt;

&lt;p&gt;The error messages arrive one at a time, so list the dependents first. The objects PostgreSQL tracks are recorded in &lt;code&gt;pg_depend&lt;/code&gt; against the column's attribute number, and &lt;code&gt;pg_describe_object&lt;/code&gt; names each one:&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="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;pg_describe_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;classid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objsubid&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;dependent&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_depend&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_attribute&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;ON&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;attrelid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;refobjid&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;attnum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;refobjsubid&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;refclassid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pg_class'&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;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;refobjid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'users'&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;attname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'is_active'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the example table it returned the partial index, the column default, &lt;code&gt;users_is_active_check&lt;/code&gt;, the view, and on PostgreSQL 18 also &lt;code&gt;users_is_active_not_null&lt;/code&gt;, the constraint 18 now records for &lt;code&gt;NOT NULL&lt;/code&gt;. That one survives the type change untouched. Everything else on the list needs a boolean rewrite or a drop, and so would a row-level security policy or extended statistics on the column, which show up here too. Views built on top of &lt;code&gt;active_users&lt;/code&gt; do not appear, since they depend on the view rather than the column; walk them with the recursive query in the view post linked above. Two kinds of code are not tracked at all: a PL/pgSQL function body with &lt;code&gt;WHERE is_active = 1&lt;/code&gt;, which lets the type change through and fails the next time it runs, and your application's queries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does converting the column lock the table?
&lt;/h2&gt;

&lt;p&gt;Yes, for the whole rewrite. &lt;code&gt;integer&lt;/code&gt; and &lt;code&gt;boolean&lt;/code&gt; are stored differently, so PostgreSQL writes a new copy of the table and rebuilds every index on it, holding &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; the entire time: no reads, no writes. On 5,000,000 rows on a laptop, with emails like &lt;code&gt;user123@example.com&lt;/code&gt;, the &lt;code&gt;TYPE&lt;/code&gt; change took 15.4 seconds and recreating the partial index another 4.4. Nothing could read &lt;code&gt;users&lt;/code&gt; for those 20 seconds.&lt;/p&gt;

&lt;p&gt;It does not buy much space either. The table itself, without its indexes, went from 365 MB to 357 MB, because a 4-byte integer and a 1-byte boolean often land in the same aligned row slot. The reason to migrate is a column that says what it means, not storage.&lt;/p&gt;

&lt;p&gt;The other cost arrives at commit. Once the column is boolean, the old application code fails on every query that sends a number:&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="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;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;is_active&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="c1"&gt;-- ERROR:  operator does not exist: boolean = integer&lt;/span&gt;

&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&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="n"&gt;is_active&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'a@example.com'&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;-- ERROR:  column "is_active" is of type boolean but expression is of type integer&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A literal &lt;code&gt;'1'&lt;/code&gt; in quotes still works, because PostgreSQL reads an untyped string as a boolean. A parameter typed as an integer does not: &lt;code&gt;PREPARE q(int) AS SELECT count(*) FROM users WHERE is_active = $1&lt;/code&gt; fails the same way. Whether your application hits this depends on the driver, since some send parameters as typed integers and some as untyped text, so test it rather than assume. Either way, the in-place change and the application change have to ship as one.&lt;/p&gt;

&lt;h2&gt;
  
  
  In place, new column, or keep the integer?
&lt;/h2&gt;

&lt;p&gt;When the table is large, or the application deploys separately from its migrations, add the boolean as a new column and swap it in at the end:&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;users&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;active&lt;/span&gt; &lt;span class="nb"&gt;boolean&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;users&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;active&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- keep the new column in step with whatever the old code writes&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;users_sync_active&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="k"&gt;trigger&lt;/span&gt; &lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
    &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;NEW&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_active&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;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;RETURN&lt;/span&gt; &lt;span class="k"&gt;NEW&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="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TRIGGER&lt;/span&gt; &lt;span class="n"&gt;users_sync_active&lt;/span&gt; &lt;span class="k"&gt;BEFORE&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;
    &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;EACH&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;EXECUTE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;users_sync_active&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;-- backfill in batches, with VACUUM users between them&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;is_active&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;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;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;BETWEEN&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="mi"&gt;100000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;CONCURRENTLY&lt;/span&gt; &lt;span class="n"&gt;users_active_email_idx_new&lt;/span&gt; &lt;span class="k"&gt;ON&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="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;active&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;users&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;users_active_not_null&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="n"&gt;active&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="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;users&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;users_active_not_null&lt;/span&gt;&lt;span class="p"&gt;;&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;'2s'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&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;TRIGGER&lt;/span&gt; &lt;span class="n"&gt;users_sync_active&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;users&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;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;users_sync_active&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;VIEW&lt;/span&gt; &lt;span class="n"&gt;active_users&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;users&lt;/span&gt; &lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;is_active&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;users&lt;/span&gt; &lt;span class="k"&gt;RENAME&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;is_active&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;users&lt;/span&gt; &lt;span class="k"&gt;RENAME&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;users_active_not_null&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;users_is_active_not_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;INDEX&lt;/span&gt; &lt;span class="n"&gt;users_active_email_idx_new&lt;/span&gt; &lt;span class="k"&gt;RENAME&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;users_active_email_idx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;active_users&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;is_active&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 trigger matters: without it, old code inserting &lt;code&gt;is_active = 0&lt;/code&gt; would get &lt;code&gt;active = true&lt;/code&gt; from the default, and rows it updates after their batch would go stale. The slow parts, the backfill and the index build, run without blocking readers, but each batch writes a new version of every row it touches, so the table grows until &lt;code&gt;VACUUM&lt;/code&gt; catches up, and the dropped column's space is only reclaimed by a later rewrite. The final transaction took about 60 ms on the same 5,000,000 rows, since dropping a column and renaming are catalogue changes, and the old column's &lt;code&gt;CHECK&lt;/code&gt; and partial index go with it. The &lt;code&gt;NOT NULL ... NOT VALID&lt;/code&gt; form is PostgreSQL 18; on 12 to 17, prove it with a &lt;code&gt;CHECK&lt;/code&gt; constraint as described in &lt;a href="https://schemity.com/blog/postgres-add-not-null-column-large-table/" rel="noopener noreferrer"&gt;adding a NOT NULL column to a large table&lt;/a&gt;. What this route shortens is the lock, not the cut-over. After the swap, &lt;code&gt;is_active&lt;/code&gt; is boolean, so code that still sends &lt;code&gt;1&lt;/code&gt; fails exactly as it would in place, and code already moved to &lt;code&gt;active&lt;/code&gt; loses that name. The simplest version keeps the new name: move the application to &lt;code&gt;active&lt;/code&gt;, then drop &lt;code&gt;is_active&lt;/code&gt; and skip the renames.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;In place&lt;/th&gt;
&lt;th&gt;New column and swap&lt;/th&gt;
&lt;th&gt;Keep the integer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lock&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; for the rewrite, 20 s on 5M rows here&lt;/td&gt;
&lt;td&gt;About 60 ms at the swap&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Old application code&lt;/td&gt;
&lt;td&gt;Fails at commit&lt;/td&gt;
&lt;td&gt;Keeps working until the cut-over&lt;/td&gt;
&lt;td&gt;Unaffected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Steps&lt;/td&gt;
&lt;td&gt;One transaction&lt;/td&gt;
&lt;td&gt;Backfill, dual writes, swap&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fits&lt;/td&gt;
&lt;td&gt;Small tables, one deploy for app and schema&lt;/td&gt;
&lt;td&gt;Large or busy tables&lt;/td&gt;
&lt;td&gt;A column that may need a third state&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Keeping the integer is a real option. With &lt;code&gt;CHECK (is_active IN (0, 1))&lt;/code&gt; the database enforces the same two values, and if a third state is plausible, an &lt;a href="https://schemity.com/blog/postgres-enum-vs-check-constraint-vs-lookup-table/" rel="noopener noreferrer"&gt;enum or a lookup table&lt;/a&gt; is the better target than boolean anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Schemity plans and reviews the change
&lt;/h2&gt;

&lt;p&gt;The point of the table above is to see the impact of every change before it runs, and none of it is visible in a one-line migration. Schemity is database design software that reads your live database, shows the impact of every schema change before it runs, and keeps the diagram as a file in Git.&lt;/p&gt;

&lt;p&gt;Change &lt;code&gt;is_active&lt;/code&gt; from &lt;code&gt;INTEGER&lt;/code&gt; to &lt;code&gt;BOOLEAN&lt;/code&gt; in a diagram connected to the database, and the field dialog clears the &lt;code&gt;1&lt;/code&gt; default, because it is not a valid boolean, and offers &lt;code&gt;TRUE&lt;/code&gt; and &lt;code&gt;FALSE&lt;/code&gt; in its place.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyioo25n3wsjkvol0jxuu.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyioo25n3wsjkvol0jxuu.webp" alt="Schemity's field dialog for users.is_active after its type is switched from INTEGER to BOOLEAN: the Default dropdown is open with No default, TRUE and FALSE, and the integer default 1 is gone; behind it the canvas still shows is_active as INTEGER with default 1 and one check constraint, next to the active_users view" width="800" height="481"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Saving the field also removes &lt;code&gt;users_is_active_check&lt;/code&gt;, because &lt;code&gt;0&lt;/code&gt; and &lt;code&gt;1&lt;/code&gt; are not values a boolean can hold. The &lt;a href="https://schemity.com/doc/migration-sql-diff/" rel="noopener noreferrer"&gt;migration SQL&lt;/a&gt; it plans drops that constraint and the old default first, converts with &lt;code&gt;USING "is_active" &amp;lt;&amp;gt; 0&lt;/code&gt;, which works on &lt;code&gt;SMALLINT&lt;/code&gt; and &lt;code&gt;BIGINT&lt;/code&gt; columns too, and sets the new default last.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F52lbi7yqb0ph1mtixs40.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F52lbi7yqb0ph1mtixs40.webp" alt="Schemity's Findings view for is_active changed from INTEGER to BOOLEAN with default TRUE: the planned migration drops users_is_active_check, drops the default, alters the column TYPE BOOLEAN USING " width="800" height="522"&gt;&lt;/a&gt; 0 and sets DEFAULT TRUE; planned changes list the altered column and the dropped check constraint, lint has no findings, and impact has 4"/&amp;gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://schemity.com/doc/impact-analysis/" rel="noopener noreferrer"&gt;Impact analysis&lt;/a&gt; then reports the cost before anything runs: a type change whose values may not survive the cast, a rewrite of &lt;code&gt;users&lt;/code&gt; with its row estimate and size on disk, the lock that holds back reads and writes while it happens, and &lt;code&gt;active_users&lt;/code&gt; as a view the database will refuse the change for.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2fq19sgo1bw19fhzzsaw.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F2fq19sgo1bw19fhzzsaw.webp" alt="Schemity's Impact drawer with 4 findings for the same change: values of users.is_active may not survive the cast on ~5M rows, a rewrite of users of ~5M rows and 695 MB on disk, the type change holding back reads and writes to users, and the view active_users depending on users.is_active so the database refuses the change; the canvas shows users with is_active BOOLEAN TRUE next to the active_users view" width="800" height="481"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two limits are worth knowing. Impact analysis lists views, materialized views, triggers, functions and generated columns that read the column, but not a partial index whose predicate compares it with a number, so run the &lt;code&gt;pg_depend&lt;/code&gt; query above as well. And Schemity's own migration is the in-place route without dropping the view or the index, so on this table the planned SQL still stops at both. For a large table, use the findings as the cue to write the new-column version in your migration tool, then paste it into the SQL migration drawer to have it analysed against the connected database without running it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which migration to write
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Small table, application and migration deployed together: in place, in one transaction, with the default dropped first and &lt;code&gt;USING col &amp;lt;&amp;gt; 0&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Large or busy table, or separate deploys: a new boolean column kept in sync by a trigger, batched backfill, an index built &lt;a href="https://schemity.com/blog/postgres-unique-constraint-vs-unique-index/" rel="noopener noreferrer"&gt;concurrently&lt;/a&gt;, and a swap of about 60 ms.&lt;/li&gt;
&lt;li&gt;A flag that might grow a third value: keep the integer with its &lt;code&gt;CHECK&lt;/code&gt;, or move to an enum, not to boolean.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>sql</category>
      <category>webdev</category>
    </item>
    <item>
      <title>What Breaks When a Database Diagram Reaches 1,000 Tables, and What We Redesigned</title>
      <dc:creator>Son Tran</dc:creator>
      <pubDate>Mon, 19 Oct 2026 04:30:41 +0000</pubDate>
      <link>https://dev.to/tbson87/what-breaks-when-a-database-diagram-reaches-1000-tables-and-what-we-redesigned-4k8f</link>
      <guid>https://dev.to/tbson87/what-breaks-when-a-database-diagram-reaches-1000-tables-and-what-we-redesigned-4k8f</guid>
      <description>&lt;p&gt;&lt;em&gt;Disclosure: I build &lt;a href="https://schemity.com" rel="noopener noreferrer"&gt;Schemity&lt;/a&gt;, a desktop ERD tool - this post is from our blog and uses it for the examples.&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Around 1,000 tables a database diagram stops being a drawing problem and becomes a scaling problem. In Schemity, drawing every table at once, comparing every line with every other, redrawing a 624-relation table on every move and painting a 134-megapixel export all broke. After the redesign a 1,035-table diagram opens in about 10 seconds and idles at 1.1 to 1.7 GB on a 16 GB M1 Pro. The next limit found is the line-jump pass, which grows much faster than the diagram.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Going from a few hundred tables to a thousand is where a database diagram stops being a drawing and becomes a performance problem. On a 1,035-table, 1,822-relation PostgreSQL diagram, four things broke in Schemity: drawing every table at once, comparing every line with every other, redrawing a 624-relation table on every move, and painting the PNG export as one 134-megapixel canvas. Each one needed a redesign, and each redesign has a cost, measured below.&lt;/p&gt;

&lt;p&gt;The result opens in about 10 seconds and sits at 1.1 to 1.7 GB with no CPU use while idle, on a 16 GB laptop. That is the user-facing answer. The rest of this post is the engineering behind it: what failed, what replaced it, what the replacement gave up, the export that needed 10.4 GB, and where the design breaks next, because it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The test diagram and how it was measured
&lt;/h2&gt;

&lt;p&gt;The diagram is a generated ERP-style PostgreSQL schema, built through Schemity's MCP server for stress testing rather than taken from a customer: 33 business domains from identity and billing to payroll and logistics, laid out in one diagram. It is the same model the app's stress test loads, and its limits as a benchmark are spelled out near the end.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;Count&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tables&lt;/td&gt;
&lt;td&gt;1,035&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Columns&lt;/td&gt;
&lt;td&gt;9,407&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Relations (foreign keys)&lt;/td&gt;
&lt;td&gt;1,822&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Most relations on one table&lt;/td&gt;
&lt;td&gt;624, on &lt;code&gt;iam_users&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Check and unique constraints&lt;/td&gt;
&lt;td&gt;1,124&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Legends (domain boxes)&lt;/td&gt;
&lt;td&gt;33&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Diagram size&lt;/td&gt;
&lt;td&gt;15,760 by 14,178 px&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Diagram file on disk&lt;/td&gt;
&lt;td&gt;13.2 MB of JSON&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The machine is an Apple M1 Pro with 8 cores and 16 GB of memory, on macOS 27, running Schemity 2.12.1. Memory is the physical footprint macOS reports, the number Activity Monitor shows, summed over the app's own process and the WebKit processes that draw its window. It was sampled at least once a second while each step ran, with a pause of about 20 seconds between steps so each one shows up on its own in the log.&lt;/p&gt;

&lt;h2&gt;
  
  
  How much memory does an ERD for a large database need?
&lt;/h2&gt;

&lt;p&gt;About 1.1 GB once it is open, rising to 1.6 to 1.7 GB after you have moved around it, with short peaks above that:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Peak memory&lt;/th&gt;
&lt;th&gt;Memory after&lt;/th&gt;
&lt;th&gt;CPU&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;App open, home screen&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;183 MB&lt;/td&gt;
&lt;td&gt;183 MB&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open the 1,035-table diagram&lt;/td&gt;
&lt;td&gt;about 10 s to usable&lt;/td&gt;
&lt;td&gt;1.9 GB&lt;/td&gt;
&lt;td&gt;1.1 GB&lt;/td&gt;
&lt;td&gt;12 s, about one core&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Move across it with the minimap and search&lt;/td&gt;
&lt;td&gt;smooth&lt;/td&gt;
&lt;td&gt;3.1 GB&lt;/td&gt;
&lt;td&gt;about 1.6 GB&lt;/td&gt;
&lt;td&gt;44 s over the whole step&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drag the 624-relation table and drop it&lt;/td&gt;
&lt;td&gt;works, with visible lag&lt;/td&gt;
&lt;td&gt;2.4 GB&lt;/td&gt;
&lt;td&gt;1.6 GB&lt;/td&gt;
&lt;td&gt;11 s over the whole step&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open Lint on the whole schema&lt;/td&gt;
&lt;td&gt;instant&lt;/td&gt;
&lt;td&gt;1.9 GB&lt;/td&gt;
&lt;td&gt;1.7 GB&lt;/td&gt;
&lt;td&gt;11 s over the whole step&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idle with the diagram open&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuu9d7ygeen1ij59m3n1b.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuu9d7ygeen1ij59m3n1b.webp" alt="Line chart of Schemity 2.12.1 whole-app memory over one 545-second run on the 1,035-table diagram: about 0.2 GB on the home screen, a 1.9 GB peak while opening that settles at 1.1 GB, a 3.1 GB peak on the first trip across the diagram, 2.4 GB while dragging the 624-relation table, 1.9 GB opening Lint, and a spike to 10.4 GB during PNG export, with a dashed line at 2.9 GB for the same export in a test build of the next release" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Almost all of the memory sits in WebKit's content process, which holds the canvas and the diagram's data. The app's own process stayed between 33 and 109 MB throughout, and the GPU process rose to about 290 MB while the view was moving and fell back below 30 MB when it stopped. With the diagram open and nothing happening, the CPU counter did not move for a minute: no CPU while idle. Each row of that table is the product of one of the redesigns below.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you open a 1,000-table diagram without drawing every table?
&lt;/h2&gt;

&lt;p&gt;You draw only what is on screen. Every table in a Schemity diagram is cached as a bitmap, so panning moves pictures instead of redrawing text. Before 2.12.1, opening a diagram rasterized every table into that cache whether it was near the screen or not, twice: once when it mounted and again when the diagram font finished loading. On 1,035 tables that was 2,070 bitmap renders and 13.7 seconds of frozen canvas after the diagram had already appeared.&lt;/p&gt;

&lt;p&gt;Now a table outside the viewport is not drawn at all. It keeps a note of what it still owes, the text layout the font change asks for and its bitmap, and pays it in the frame where it first scrolls into view. Opening costs only the tables you can see, which is how the same diagram became usable in about 10 seconds.&lt;/p&gt;

&lt;p&gt;The cost is memory that grows as you explore. A table that has been drawn keeps its bitmap, so panning back over it is cheap, but the cache only grows. In this run, moving around the diagram took it from 1.1 GB to about 1.6 GB, and the 3.1 GB peak in the table came during that first trip across.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does comparing every line with every other stop working?
&lt;/h2&gt;

&lt;p&gt;Because the number of pairs grows with the square of the lines. Where two relation lines cross, Schemity draws a small hop so you can tell which line goes where, and finding the crossings means testing segments against each other. The 1,822 relations here are 21,254 straight segments, which is 226 million pairs, and testing them all took 1.4 seconds, after every single drop.&lt;/p&gt;

&lt;p&gt;The replacement sorts the segments by their left edge and sweeps across them, so a segment is only tested against the segments whose horizontal range overlaps its own. It finds exactly the same crossings, in about 0.2 seconds. The pass was also moved out of the frame that shows the drop: the table lands and the snap guide clears at once, and the hops follow a moment later. Until they do, a line that just moved is drawn without hops rather than with hops at its old crossings.&lt;/p&gt;

&lt;p&gt;That sweep has a known weakness, and it is the first limit in the scaling run further down: it only prunes by horizontal position. Segments stacked in the same vertical band are still tested against each other, and a bigger diagram stacks more of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you drag a table with 624 relations?
&lt;/h2&gt;

&lt;p&gt;By not drawing the real lines while it moves. A high-degree table is the worst case for an interactive diagram, and &lt;code&gt;iam_users&lt;/code&gt;, with 624 relations, made every part of the drag path visible:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every move re-rendered, committed and redrew all 624 routed relations.&lt;/li&gt;
&lt;li&gt;Starting and ending a drag re-rendered every one of the 1,035 tables, twice, because of one diagram-wide dragging flag, at over a second of React work each time.&lt;/li&gt;
&lt;li&gt;Every table carried its selection ring, resize handles and other controls whether selected or not, about 5,000 components, and two of them loaded an icon before drawing nothing, so every drop set off about 2,000 icon loads.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now the dragging state is shared by reference instead of passed to every table, the controls exist only on the selected table, and a table with 30 or more relations is dragged through a lightweight preview. Its real lines are hidden, and one shape draws a straight line from its centre to the centre of each related table, re-aimed directly on each move with no routing and no React render. Aiming all 624 lines costs under 0.1 ms per frame, and on drop the 624 real routes are recomputed in about 7 ms and drawn once.&lt;/p&gt;

&lt;p&gt;This is a deliberate trade of looks for usability. For the length of the drag the lines run straight through whatever lies between, instead of routed around the other tables with clean right angles, so the diagram looks rougher than it really is. The properly routed lines come back the moment you drop. Even so, the drag is the one step that does not feel smooth: the table follows the pointer with a visible lag instead of stalling as it did before, but it is not the fluid drag a small table gets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why did exporting the diagram to PNG need 10 GB?
&lt;/h2&gt;

&lt;p&gt;Because the image was drawn the way a browser draws anything: into one canvas. Schemity's window is a web view, and a 1,035-table diagram exported at the largest scale the pixel budget allows, about 0.77 of its size on screen, is 12,208 by 10,994 pixels, 134 million pixels in one canvas. Before 2.12.1 the export always asked for its usual 2x scale, a 909-million-pixel canvas the browser refused, and silently wrote no file. 2.12.1 fits the scale to the pixel budget, and the export works, but the whole app went from 1.7 GB to a 10.4 GB peak, more than an 8 GB laptop has. An instrumented build showed where most of the jump came from: drawing the diagram into the canvas took 3.2 seconds and added about 3.3 GB, and WebKit's PNG encoder added about another 3.2 GB. JPEG, which goes through the same canvas, cost about the same.&lt;/p&gt;

&lt;p&gt;We confirmed it was the canvas and not the diagram by exporting the same diagram as SVG, which writes the drawing out as text and never creates a canvas. That export added about 240 MB. So the next release no longer asks the browser to paint the image. On desktop, PNG and JPEG export now take the SVG export and render it natively with resvg, an SVG renderer written in Rust, encoding the PNG row by row so the pixels are never copied whole:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Export of the 1,035-table diagram&lt;/th&gt;
&lt;th&gt;2.12.1&lt;/th&gt;
&lt;th&gt;Next release (test build)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;PNG peak, whole app&lt;/td&gt;
&lt;td&gt;10.4 GB&lt;/td&gt;
&lt;td&gt;2.9 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JPEG peak, whole app&lt;/td&gt;
&lt;td&gt;about the same as PNG&lt;/td&gt;
&lt;td&gt;2.8 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SVG, added while exporting&lt;/td&gt;
&lt;td&gt;about 240 MB&lt;/td&gt;
&lt;td&gt;unchanged&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image size&lt;/td&gt;
&lt;td&gt;12,208 by 10,994 px&lt;/td&gt;
&lt;td&gt;the same&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The next-release figures come from a test build with the export code optimized as it will ship. The renderer alone, timed outside the app, peaks at about 925 MB and takes 8.7 seconds for this diagram, against about 7 seconds for the whole old export. One detail cost a factor of three: with every installed font available for fallback, a copy of the diagram font installed on the test machine competed with the bundled one and was mapped from disk again for each of the 27,811 text elements, which took the render from 8.5 to 29 seconds until the bundled copy was made the only one. The price is 2.3 MB more app binary, for the renderer and that bundled font. Until the release reaches you, export a diagram this size as SVG: it is the cheap path today.&lt;/p&gt;

&lt;h2&gt;
  
  
  How long does it take an AI agent to route 1,822 relations?
&lt;/h2&gt;

&lt;p&gt;It used to be never. Schemity's MCP server lets an AI agent sort a large schema into domains with one call, &lt;code&gt;group_entities&lt;/code&gt;, which places the legends and then routes every relation around the tables. On 1,035 tables and 1,822 relations that call never finished. Each re-routing pass rebuilt its record of where every other route already ran, once per relation, and re-totalled the whole layout twice for every attempt.&lt;/p&gt;

&lt;p&gt;The router now takes one route out of a running count and puts it back, and prices only the line that changed. Its A* search keeps its best costs in flat arrays reused across searches instead of hash maps. Routes came out byte-identical on 162- and 400-table diagrams, and 400 tables went from 26.8 seconds to 3.6. Past 500 relations the re-routing stops after 2,000 searches in total, a count rather than a clock, so the same input still gives the same routes. When that change landed, 1,035 tables in 33 domains went from over 3 minutes to about 85 seconds end to end. A later change compiled the routing engine for speed instead of size, which cut a full 1,822-relation routing pass from 33.2 to 22.0 seconds for 0.4 MB of app binary; the end-to-end call was not re-timed after it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does opening on the Context Map use less memory?
&lt;/h2&gt;

&lt;p&gt;In the next release, yes: a diagram can open on its Context Map instead of the full canvas. A context view is a saved view of the diagram that shows only the tables you picked and the relations between them, and the Context Map shows every context view as one box with its table count, with arrows between boxes that come from the foreign keys. The canvas behind the main diagram is now built only the first time something other than the map is shown, so a diagram set to open on the map draws none of its 1,035 tables on the way in.&lt;/p&gt;

&lt;p&gt;To measure it, this diagram's 33 domains became 33 context views, one per legend, which grew the file from 13.2 to 15.3 MB. The diagram was then set to open on the map, with Open on in its connection settings, and opened in a local release build of the next version, sampled the same way:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Peak memory&lt;/th&gt;
&lt;th&gt;Memory after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Open the diagram on its &lt;a href="https://schemity.com/doc/context-map/" rel="noopener noreferrer"&gt;Context Map&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;about 4 s&lt;/td&gt;
&lt;td&gt;0.74 GB&lt;/td&gt;
&lt;td&gt;0.46 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enter the Sales context view, 36 tables&lt;/td&gt;
&lt;td&gt;instant&lt;/td&gt;
&lt;td&gt;0.69 GB&lt;/td&gt;
&lt;td&gt;0.47 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Switch to the main diagram, all 1,035 tables&lt;/td&gt;
&lt;td&gt;about 3 s&lt;/td&gt;
&lt;td&gt;1.5 GB&lt;/td&gt;
&lt;td&gt;1.2 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Against opening straight onto the main diagram in 2.12.1 (1.9 GB peak, 1.1 GB after, about 10 s), opening on the map in the next version's build settles at about 40% of the memory in less than half the time. Someone who works in one domain settles under half a gigabyte, which matters more on an 8 GB laptop than any single peak. The full diagram is still one step away, and with the data already loaded it takes about 3 s.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does the architecture break next?
&lt;/h2&gt;

&lt;p&gt;To find out without a bigger real schema, the engine was run on copies of this diagram: the same 1,035 tables tiled 2, 4 and 8 times across a larger canvas, each copy renamed so nothing collides, keeping the real routed lines and one 624-relation hub per copy. These are engine timings on the same M1 Pro, run in Node outside the app, so they exclude drawing:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tables&lt;/th&gt;
&lt;th&gt;Relations&lt;/th&gt;
&lt;th&gt;Line jumps, after every drop&lt;/th&gt;
&lt;th&gt;Lint, whole schema&lt;/th&gt;
&lt;th&gt;Recompute the hub's 624 relations on drop&lt;/th&gt;
&lt;th&gt;Drag preview, per frame&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1,035&lt;/td&gt;
&lt;td&gt;1,822&lt;/td&gt;
&lt;td&gt;0.19 s&lt;/td&gt;
&lt;td&gt;0.04 s&lt;/td&gt;
&lt;td&gt;6.9 ms&lt;/td&gt;
&lt;td&gt;0.06 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2,070&lt;/td&gt;
&lt;td&gt;3,644&lt;/td&gt;
&lt;td&gt;0.48 s&lt;/td&gt;
&lt;td&gt;0.07 s&lt;/td&gt;
&lt;td&gt;5.4 ms&lt;/td&gt;
&lt;td&gt;0.04 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4,140&lt;/td&gt;
&lt;td&gt;7,288&lt;/td&gt;
&lt;td&gt;2.1 s&lt;/td&gt;
&lt;td&gt;0.13 s&lt;/td&gt;
&lt;td&gt;5.6 ms&lt;/td&gt;
&lt;td&gt;0.07 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8,280&lt;/td&gt;
&lt;td&gt;14,576&lt;/td&gt;
&lt;td&gt;5.5 s&lt;/td&gt;
&lt;td&gt;0.28 s&lt;/td&gt;
&lt;td&gt;8.2 ms&lt;/td&gt;
&lt;td&gt;0.04 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three of the four scale well. Lint grows in step with the schema. The drag of a 624-relation table costs the same at 8,280 tables as at 1,035, because the drag only touches that table's own relations: its cost follows the table's degree, not the diagram's size, which is the property the preview was built for.&lt;/p&gt;

&lt;p&gt;The line-jump pass does not. Eight times the relations cost 29 times the time, and it grows faster than the diagram from the first doubling: 2.6 times the time for twice the relations, then 4.3 times for the next doubling. The cause is the horizontal sweep from earlier, which still tests every pair of segments that share a vertical band, and a larger diagram stacks more of them. How much depends on layout: the copies here are tiled in a grid, so they share columns, and a wider or taller arrangement would give a different curve. The pass runs on the main thread after any change to the relations, a drop, an edit or an undo, and once synchronously while the diagram opens, so at 8,280 tables it would hold the window for about 5.5 seconds each time in Node. WebKit was not measured at that size. A grid that also prunes by vertical position would remove that weakness.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this benchmark does not show
&lt;/h2&gt;

&lt;p&gt;Publishing the numbers is only useful with their limits next to them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One synthetic schema.&lt;/strong&gt; The model is generated, and its topology is cleaner than a real legacy database: every table sits in exactly one domain, and Schemity's own router laid out every line. A real schema can have tables that relate to everything, inferred relations with no foreign key behind them, and a layout nobody planned.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Modest density.&lt;/strong&gt; 1,822 relations over 1,035 tables is about 1.8 per table. The single 624-relation hub is the deliberate worst case, but a schema that is dense everywhere would stress the line-jump pass sooner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One machine.&lt;/strong&gt; Every number comes from a 16 GB M1 Pro. There are no measurements here for an 8 GB laptop, Windows or Linux, where the web view is a different engine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No full-app curve past 1,035 tables.&lt;/strong&gt; The scaling table is engine-only, and it repeats one topology rather than growing a new one. Memory, drawing and export were not measured on the larger copies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test builds for what is unreleased.&lt;/strong&gt; The native export and Open on figures come from builds of the next release, not from a shipped version.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Is it worth keeping a 1,000-table schema in one diagram?
&lt;/h2&gt;

&lt;p&gt;Yes, in one file, and no, not on one screen. The point of loading the whole schema is that nothing is missing when you go looking: every table and every foreign key between domains is in the diagram, and on a real database Schemity reads them from the live schema, so you can understand your schema without first deciding which half to ignore. Reading it is a different job, done one domain at a time.&lt;/p&gt;

&lt;p&gt;Schemity is database design software that reads your live database, shows the impact of every schema change before it runs, and keeps the diagram as a file in Git. A payroll engineer can open the payroll context view without the other 1,000 tables around it. &lt;a href="https://schemity.com/doc/context-views/" rel="noopener noreferrer"&gt;Context views&lt;/a&gt; and &lt;a href="https://schemity.com/doc/legends-and-annotations/" rel="noopener noreferrer"&gt;legends&lt;/a&gt; are how this diagram's 33 domains stay readable, and &lt;a href="https://schemity.com/doc/schema-lint/" rel="noopener noreferrer"&gt;Lint&lt;/a&gt; checks all 1,035 tables at once whichever view you are in. The diagram file itself is plain JSON, 13.2 MB for this schema, so it &lt;a href="https://schemity.com/doc/version-control-git/" rel="noopener noreferrer"&gt;lives in Git&lt;/a&gt; like the code it describes.&lt;/p&gt;

&lt;p&gt;If the large schema is one you inherited, &lt;a href="https://schemity.com/blog/reverse-engineer-legacy-database-group-by-domain/" rel="noopener noreferrer"&gt;grouping a legacy database by domain&lt;/a&gt; covers how to get from a wall of tables to named domains, with an AI agent doing the first pass. And for what a diagram of a live database should and should not let an agent touch, see &lt;a href="https://schemity.com/blog/database-mcp-server-sql-access-or-schema-only/" rel="noopener noreferrer"&gt;what a database MCP server should let an agent do&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>database</category>
      <category>performance</category>
      <category>postgres</category>
      <category>erd</category>
    </item>
  </channel>
</rss>
