How Not to Migrate to Django's JSONField
While testing a development deployment of OpenWISP recently, I ended up debugging a migration issue that turned out to be much more interesting than I expected.
The OpenWISP community has been working on removing the dependency on the
unmaintained jsonfield package and
replacing it with Django’s native
JSONField.
By the time I started looking into this work, most of the migration had already
been done and patches had been submitted across several modules.
At first glance, the migration looked straightforward. The old
jsonfield.JSONField was replaced with Django’s native JSONField, the
dependency could be removed, and everything seemed to work.
At least on SQLite.
I only started noticing problems when I deployed the development version against
PostgreSQL. Some JSON-related operations were failing, which was confusing
because the models were already using Django’s native JSONField.
My first assumption was that there was something wrong with the queries being generated by the ORM. After spending some time tracing things backwards, I noticed that the migration wasn’t limited to model definitions. Historical migrations had also been updated.
The original implementation used the
jsonfield package, which stores
serialized JSON in text-based database columns. Django’s native JSONField, on
PostgreSQL, uses the database’s native JSON support instead.
While tracing the issue, I noticed that some historical migrations had been
modified. References to jsonfield.JSONField had been replaced with Django’s
native JSONField in migrations that had already been applied years ago.
That was the point where things started to click.
Existing installations had already run those migrations, so changing the files
didn’t change the database schema. The migrations now described native JSON
fields, while the actual database columns were still the original text-based
columns created by the jsonfield package.
What made this especially confusing was that Django didn’t generate a migration to fix the discrepancy.
Eventually I realized why: Django compares models against migration state, not
against the live database schema. The migration state already contained a
JSONField, and so did the current models, so Django saw no changes to apply.
This also explained why I couldn’t reproduce the issue locally on SQLite. SQLite
doesn’t have a native JSON type like PostgreSQL’s jsonb. JSON values are
stored as text and JSON functionality is provided through the JSON1 extension.
The difference between the old jsonfield package and Django’s native
JSONField is therefore much less visible on SQLite than it is on PostgreSQL.
Once I understood what was happening, the fix became fairly straightforward.
We restored the historical migrations so they accurately reflected the schema that existing databases actually had. After that, Django could finally see the difference between the migration state and the current model definitions and generate a proper migration.
That migration performed the actual conversion to Django’s native JSONField
and recreated the affected indexes where necessary.
What I found interesting about this whole debugging session is that the problem had very little to do with JSON itself. The real issue was that we had changed Django’s understanding of the schema history without changing the schema of databases that had already applied those migrations.
After working through this issue, I opened a discussion in the OpenWISP community about strengthening our upgrade testing strategy. One of the ideas I’m pushing for is testing upgrade paths against PostgreSQL in CI, since this bug remained invisible until it was exercised on a different database backend.