<?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: Houston Wong</title>
    <description>The latest articles on DEV Community by Houston Wong (@houston_wong_78b96dd3a773).</description>
    <link>https://dev.to/houston_wong_78b96dd3a773</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3310780%2F7abab6b9-5b49-463b-864a-78d26c002ed8.png</url>
      <title>DEV Community: Houston Wong</title>
      <link>https://dev.to/houston_wong_78b96dd3a773</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/houston_wong_78b96dd3a773"/>
    <language>en</language>
    <item>
      <title>Fixing Django Squashed Migration in Multi-App Issue #36168</title>
      <dc:creator>Houston Wong</dc:creator>
      <pubDate>Mon, 07 Jul 2025 18:35:40 +0000</pubDate>
      <link>https://dev.to/houston_wong_78b96dd3a773/fixing-django-squashed-migration-in-multi-app-issue-36168-5g39</link>
      <guid>https://dev.to/houston_wong_78b96dd3a773/fixing-django-squashed-migration-in-multi-app-issue-36168-5g39</guid>
      <description>&lt;h2&gt;
  
  
  Introduction:
&lt;/h2&gt;

&lt;p&gt;This article explains a potential fix for Django back-migration failures caused by squashed migrations in multi-app. &lt;br&gt;
This issue was originally reported in the Django issue: &lt;a href="https://code.djangoproject.com/ticket/36168" rel="noopener noreferrer"&gt;https://code.djangoproject.com/ticket/36168&lt;/a&gt;&lt;br&gt;
with a reproducible example provided here: &lt;a href="https://github.com/vanschelven/squashwithrename" rel="noopener noreferrer"&gt;https://github.com/vanschelven/squashwithrename&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the reproducible example project, there are two Django apps: squashme and triggerfailingcode. Both apps use squashed migrations. The issue appears when triggerfailingcode attempts to back-migrate to 0001_initial, triggering this error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FieldDoesNotExist: squashme.Foo has no field named 'name'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This becomes a problem because if you remove either one of the squashed migration files, back-migrating to 0001_initial works without error. That means there is likely a bug in how Django handles multiple apps with squashed migrations.&lt;/p&gt;

&lt;h2&gt;
  
  
  _create_project_state Debug:
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Traceback (most recent call last):
  File "/Users/houston/Desktop/Contribution/squashwithrename/manage.py", line 22, in &amp;lt;module&amp;gt;
    main()
  File "/Users/houston/Desktop/Contribution/squashwithrename/manage.py", line 18, in main
    execute_from_command_line(sys.argv)
  File "/Users/houston/Desktop/Contribution/django/django/core/management/__init__.py", line 442, in execute_from_command_line
    utility.execute()
  File "/Users/houston/Desktop/Contribution/django/django/core/management/__init__.py", line 436, in execute
    self.fetch_command(subcommand).run_from_argv(self.argv)
  File "/Users/houston/Desktop/Contribution/django/django/core/management/base.py", line 416, in run_from_argv
    self.execute(*args, **cmd_options)
  File "/Users/houston/Desktop/Contribution/django/django/core/management/base.py", line 460, in execute
    output = self.handle(*args, **options)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/houston/Desktop/Contribution/django/django/core/management/base.py", line 107, in wrapper
    res = handle_func(*args, **kwargs)
          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/houston/Desktop/Contribution/django/django/core/management/commands/migrate.py", line 302, in handle
    pre_migrate_state = executor._create_project_state(with_applied_migrations=True)
                        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/houston/Desktop/Contribution/django/django/db/migrations/executor.py", line 120, in _create_project_state
    migration.mutate_state(state, preserve=False)
  File "/Users/houston/Desktop/Contribution/django/django/db/migrations/migration.py", line 91, in mutate_state
    operation.state_forwards(self.app_label, new_state)
  File "/Users/houston/Desktop/Contribution/django/django/db/migrations/operations/fields.py", line 294, in state_forwards
    state.rename_field(
  File "/Users/houston/Desktop/Contribution/django/django/db/migrations/state.py", line 313, in rename_field
    raise FieldDoesNotExist(
django.core.exceptions.FieldDoesNotExist: squashme.foo has no field named 'name'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We started with &lt;strong&gt;_create_project_state&lt;/strong&gt;, where the it fails. This function simulates what the database schema would look like if all migrations were applied — but without actually touching the database.&lt;/p&gt;

&lt;p&gt;I think it's actually great that we have a working case(remove the squashed migrations file in triggerfailingcode ) — because it gives us a reference point, a "known-good" scenario that we can compare against. It helps us better understand where things go wrong. The states are build based on full plan in &lt;strong&gt;_create_project_state&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;full_plan = self.migration_plan(
                self.loader.graph.leaf_nodes(), clean_start=True
            )
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  full plan Debug:
&lt;/h2&gt;

&lt;p&gt;Here’s the full migration plan when both squashed files are present (this is the failing case):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;******[DEBUG] full plan: [('squashme', '0001_initial'), ('squashme', '0002_rename_name_foo_rename_squashed_0003_foo_another_field'), ('squashme', '0002_rename_name_foo_rename'), ('squashme', '0003_foo_another_field'), ('triggerfailingcode', '0001_squashed_0002_baz_baz'), ('triggerfailingcode', '0001_initial'), ('triggerfailingcode', '0002_baz_baz')]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now compare that to the plan when only one squashed file is present (this is the working case):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;******[DEBUG] full plan: [('squashme', '0001_initial'), ('squashme', '0002_rename_name_foo_rename_squashed_0003_foo_another_field'), ('triggerfailingcode', '0001_initial'), ('triggerfailingcode', '0002_baz_baz')]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As you can see in the failing case, after applying &lt;strong&gt;0002_rename_name_foo_rename_squashed_0003_foo_another_field&lt;/strong&gt;, Django still tries to apply the individual migrations &lt;strong&gt;0002_rename_name_foo_rename&lt;/strong&gt; and &lt;strong&gt;0003_foo_another_field&lt;/strong&gt;.&lt;br&gt;
That’s a problem — because &lt;strong&gt;0002_rename_name_foo_rename&lt;/strong&gt; tries to rename the name field, but the squashed migration already did that. So the migration system tries to rename a field that no longer exists — and that’s when it breaks.&lt;/p&gt;

&lt;p&gt;So we can conclude that the problem lies in how the full migration plan is built:&lt;/p&gt;

&lt;p&gt;There are two parts of the full plan function:&lt;br&gt;
&lt;strong&gt;self.loader.graph.leaf_nodes()&lt;/strong&gt; &lt;em&gt;returns all migrations that have no other migration in the same app depending on them. These are seen as the latest migrations for each app.&lt;/em&gt;&lt;br&gt;
&lt;strong&gt;self.migration_plan(…)&lt;/strong&gt; &lt;em&gt;to the rest to complete the full plan&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Ideally, if we could fix the issue at the &lt;strong&gt;leaf_nodes()&lt;/strong&gt; or &lt;strong&gt;migration_plan()&lt;/strong&gt; level — without breaking any other logic — that would be the cleanest solution. However, after some investigation, it's might be neither part is a not great place to apply the fix.&lt;/p&gt;
&lt;h2&gt;
  
  
  migration_plan Debug:
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;elif (
                self.loader.replace_migrations
                and target not in self.loader.graph.node_map
            ):
                self.loader.replace_migrations = False
                print(f"******[DEBUG] REBULD node Before: {[i for i in self.loader.graph.nodes if i[0] in ["triggerfailingcode", "squashme"] ]}")
                self.loader.build_graph()
                print(f"******[DEBUG] REBULD node After: {[i for i in self.loader.graph.nodes if i[0] in ["triggerfailingcode", "squashme"] ]}")
                return self.migration_plan(targets, clean_start=clean_start)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The problem is in migration_plan. If the target node is not found in &lt;strong&gt;self.loader.graph.node_map&lt;/strong&gt;, that it's a replaced migration, Django will call &lt;strong&gt;self.loader.build_graph()&lt;/strong&gt; — which rebuilds the entire migration graph.&lt;/p&gt;

&lt;p&gt;In this case, the target is: (&lt;strong&gt;('triggerfailingcode', '0001_initial'), True)&lt;/strong&gt;.  But this migration has been replaced by &lt;strong&gt;0001_squashed_0002_baz_baz&lt;/strong&gt;.&lt;br&gt;
Since it's not in the graph nodes , Django triggers a rebuild to try to resolve it.&lt;/p&gt;

&lt;p&gt;before it rebuld:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;******[DEBUG] REBULD node Before: [('squashme', '0001_initial'), ('squashme', '0002_rename_name_foo_rename_squashed_0003_foo_another_field'), ('triggerfailingcode', '0001_squashed_0002_baz_baz')]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;after it build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;******[DEBUG] REBULD node After: [('squashme', '0002_rename_name_foo_rename'), ('squashme', '0001_initial'), ('squashme', '0002_rename_name_foo_rename_squashed_0003_foo_another_field'), ('squashme', '0003_foo_another_field'), ('triggerfailingcode', '0001_initial'), ('triggerfailingcode', '0002_baz_baz'), ('triggerfailingcode', ‘0001_squashed_0002_baz_baz')]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;0002_rename_name_foo_rename&lt;/strong&gt; and &lt;strong&gt;0003_foo_another_field&lt;/strong&gt; are added — but they shouldn't be, because their logic is already included in the squashed migration 0002_rename_name_foo_rename_squashed_0003_foo_another_field.&lt;/p&gt;

&lt;p&gt;On the other hand, triggerfailingcode: &lt;strong&gt;0001_initial&lt;/strong&gt; and &lt;strong&gt;0002_baz_baz&lt;/strong&gt; are also included — and in this case, that’s correct, because Django needs both in the plan in order to back-migrate through unapplied &lt;strong&gt;0002_baz_baz&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  To sum up the issue:
&lt;/h2&gt;

&lt;p&gt;When Django performs a back-migration and encounters a replaced migration, &lt;strong&gt;migration_plan&lt;/strong&gt; will rebuild the graph for all apps, not just the one involved. As a result, in this case, the squashme app gets reloaded — and both the squashed and unsquashed migrations end up in the graph, leading to duplicated application and failure.&lt;br&gt;
The ideal behavior should be:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Only rebuild the graph nodes for the specific app_label that needs it, leave the other apps' graph state untouched&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This issue doesn’t occur when there's only one app using squashed migrations, because rebuilding just that app’s graph helps Django properly determine what to unapply.&lt;br&gt;
But in a multi-app setup, rebuilding unrelated apps can cause serious issues — like reintroducing migrations that were already replaced.&lt;/p&gt;
&lt;h2&gt;
  
  
  Passing app_label Down Is Too Aggressive
&lt;/h2&gt;

&lt;p&gt;If we wanted to fix this properly by limiting the rebuild to just the affected app, we’d need to pass the app_label all the way down through the migration system — from the back-migration command, through &lt;strong&gt;migration_plan&lt;/strong&gt;, and ultimately into &lt;strong&gt;leaf_nodes()&lt;/strong&gt; and &lt;strong&gt;build_graph()&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That would require significant changes across Django’s internals. It’s not practical, and it would introduce complexity and risk of side effects elsewhere.&lt;/p&gt;

&lt;p&gt;So while rebuilding only the affected app’s graph is the ideal behavior in theory, it’s not an ideal solution in practice — at least not without a major redesign of Django’s migration internals.&lt;/p&gt;

&lt;p&gt;Also, we can’t simply “freeze” the old nodes and prevent Django from rebuilding the graph — because in many cases, the rebuild is necessary.&lt;/p&gt;

&lt;p&gt;For example, look at the state before the graph is rebuilt: it only includes &lt;strong&gt;0001_squashed_0002_baz_baz&lt;/strong&gt; for the triggerfailingcode app. At that point, the graph has no knowledge of &lt;strong&gt;0001_initial&lt;/strong&gt; or &lt;strong&gt;0002_baz_baz&lt;/strong&gt;, which are required for Django to correctly unapply the unapplied migrations during a backward migration.&lt;/p&gt;

&lt;p&gt;So, without rebuilding the graph, Django would not even know what needs to be unapplied — it would just crash.&lt;/p&gt;

&lt;p&gt;That means rebuilding the graph is a correct step — but it becomes unsafe when it rebuilds nodes of all app, especially apps that don’t need to be touched.&lt;/p&gt;
&lt;h2&gt;
  
  
  Safer Approach
&lt;/h2&gt;

&lt;p&gt;This approach is safer and only requires changes in two functions: &lt;strong&gt;_create_project_state&lt;/strong&gt; and &lt;strong&gt;_migrate_all_backwards&lt;/strong&gt;.&lt;br&gt;
The idea is simple: _create_project_state doesn't need the full migration dependency tree — it just needs enough information to simulate the correct state.&lt;br&gt;
For example, if I'm backward migrating triggerfailingcode to &lt;strong&gt;0001_squashed_0002_baz_baz&lt;/strong&gt;, then &lt;strong&gt;_create_project_state&lt;/strong&gt; should not care about the individual migrations that this squashed file replaces (&lt;strong&gt;0001_initial&lt;/strong&gt;, &lt;strong&gt;0002_baz_baz&lt;/strong&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; if  (migration.app_label, migration.name) in replaced_migration:
                        print(f"[DEBUG] _create_project_state skip: {migration.app_label}.{migration.name}")
                        continue
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why? Because the squashed migration already includes all the logic required to build the correct state. There's no need to reintroduce or apply the original migrations it replaces.&lt;br&gt;
We don’t change the full migration plan. Instead, during _create_project_state, &lt;strong&gt;we simply skip calling mutate_state for any migration that has been replaced.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We keep the full plan — and we must — because we still need migrations like &lt;strong&gt;0001_initial&lt;/strong&gt; or &lt;strong&gt;0002_baz_baz&lt;/strong&gt; in the plan so they can be unapplied during _migrate_all_backwards which is the second function we need to modify.&lt;/p&gt;

&lt;p&gt;Even without passing app_label explicitly through the entire back-migration flow, we already know app_label without any change. We can get the app label directly from the &lt;strong&gt;plan&lt;/strong&gt; in &lt;strong&gt;_migrate_all_backwards&lt;/strong&gt;, because that &lt;strong&gt;plan&lt;/strong&gt; contains the migrations Django is about to unapplied And their app label must match the one passed to the management command, like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;python manage.py migrate triggerfailingcode 0001_initial
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We can’t simply reuse the logic from _create_project_state by skipping all replaced migrations — doing so would break things.&lt;/p&gt;

&lt;p&gt;Why? For the target app (triggerfailingcode), we still need migrations like &lt;strong&gt;0001_initial&lt;/strong&gt; and &lt;strong&gt;0002_baz_baz&lt;/strong&gt; to invoke mutate_state(). Without them, Django can’t properly simulate the project state, nor can it determine how to unapply &lt;strong&gt;0002_baz_baz&lt;/strong&gt; later in the process.&lt;/p&gt;

&lt;p&gt;What we need instead is a more precise condition for _migrate_all_backwards:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If a migration is replaced and its app label is not the one being back-migrated, skip it.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;if  (migration.app_label, migration.name) in replaced_migration and migration.app_label not in unapply_migrations:
                    print(f"[DEBUG] _migrate_all_backwards SKIPPED {migration.app_label}.{migration.name}")
                    continue
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This adjustment ensures replaced migrations are correctly ignored only when they’re irrelevant to the app_label. As a result, this logic fixes the issue and passes the regression tests without breaking existing behavior.&lt;/p&gt;

&lt;p&gt;To help identify and debug the problem, I created a small project here:&lt;br&gt;
&lt;a href="https://github.com/houston0222/django-debug-36168" rel="noopener noreferrer"&gt;https://github.com/houston0222/django-debug-36168&lt;/a&gt;&lt;br&gt;
This project clearly shows where things break during back-migration and was key to understanding the issue.&lt;/p&gt;

</description>
      <category>django</category>
    </item>
  </channel>
</rss>
