pandafy@dev-logs:~$

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.