Opened 4 months ago

Closed 4 months ago

Last modified 4 months ago

#36371 closed Bug (worksforme)

JSONField.from_db_value crashes when DB returns parsed JSON despite KeyTransform guard

Reported by: Mason Pitts Owned by:
Component: Database layer (models, ORM) Version: 5.2
Severity: Normal Keywords: jsonfield, from_db_value, double-decoding, psycopg3, cx_oracle, python-oracledb
Cc: Mason Pitts Triage Stage: Unreviewed
Has patch: no Needs documentation: no
Needs tests: no Patch needs improvement: no
Easy pickings: no UI/UX: no

Description

In Django 5.2, the default implementation of JSONField.from_db_value() only skips double-decoding when the ORM expression is a KeyTransform on a non-string. However, many modern database drivers (e.g. PostgreSQL psycopg3, Oracle DB_TYPE_JSON via cx_Oracle 8.1+/python-oracledb) will automatically deserialize JSON columns into native Python types (dict, list) before Django sees them. Since from_db_value() still unconditionally calls json.loads() in most cases, you get:

TypeError: the JSON object must be str, bytes or bytearray, not list

even though the value is already a valid Python object.

Here is the current code below as of 5/6/2025.

    def from_db_value(self, value, expression, connection):
        if value is None:
            return value
        # Some backends (SQLite at least) extract non-string values in their
        # SQL datatypes.
        if isinstance(expression, KeyTransform) and not isinstance(value, str):
            return value
        try:
            return json.loads(value, cls=self.decoder)
        except json.JSONDecodeError:
            return value

Here is a potential solution that attempts to return value if it is a Python type.

    def from_db_value(self, value, expression, connection):
        if value is None:
            return None

        # Decode binary data first.
        if isinstance(value, (bytes, bytearray)):
            value = value.decode()

        # If value isn’t a string at this point, the driver already gave us
        # a native Python type (dict, list, bool, int, float, ...).
        if not isinstance(value, str):
            return value

        try:
            return json.loads(value, cls=self.decoder)
        except json.JSONDecodeError:
            return value

Steps to reproduce:

  1. Define a model with a models.JSONField().
  1. Use a database and driver combination that natively decodes JSON columns—for example:
  • PostgreSQL with psycopg3
  • Oracle 21c+ JSON column type with cx_Oracle 8.1+ or python-oracledb in thin mode
  • I encountered this problem using oracle_db in thin mode.
  1. Query the model in the Django admin or via MyModel.objects.all().
  1. Observe the traceback raising a TypeError when json.loads() is fed a list or dict.

Version information:

Django: 5.2

Python: 3.12.10

Affected drivers/backends:

PostgreSQL with psycopg3

Oracle 21c+ with cx_Oracle 8.1+ / python-oracledb in thin mode

Change History (2)

comment:1 by Natalia Bidart, 4 months ago

Easy pickings: unset
Has patch: unset
Resolution: worksforme
Status: newclosed

Hello Mason Pitts, thank you for taking the time to create this report. I can't reproduce what you are describing: I'm using Django main, PostgreSQL 16.8 and psycopg version 3.2.3. Given this model:

class VulnerableModel(models.Model):
    data = models.JSONField()

And this test case:

class Ticket36371TestCase(TestCase):

    def test_try(self):
        from django.db import connection
        print(connection.connection)

        from .models import VulnerableModel
        self.assertEqual(VulnerableModel.objects.all().count(), 0)

        VulnerableModel.objects.create(data={"test": 1})
        self.assertEqual(VulnerableModel.objects.all().count(), 1)

        VulnerableModel.objects.create(data=["my list", 1, 2])
        self.assertEqual(VulnerableModel.objects.all().count(), 2)

        VulnerableModel.objects.create(data="This is a plain string")
        self.assertEqual(VulnerableModel.objects.all().count(), 3)

        items = [i.data for i in VulnerableModel.objects.all()]
        self.assertEqual(items, [{"test": 1}, ["my list", 1, 2], "This is a plain string"])

Running the tests shows:

$ python -Wall manage.py test testapp.tests.Ticket36371TestCase
Found 1 test(s).
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
<psycopg.Connection [INTRANS] (user=nessita database=test_djangotest) at 0x754ee7715f90>
.
----------------------------------------------------------------------
Ran 1 test in 0.003s

OK
Destroying test database for alias 'default'...

Given this, I think this report seems better suited to be a support request. The best place to get answers to your issue is using any of the user support channels from this link.

Since the goal of this issue tracker is to track issues about Django itself, and your issue seems, at first, to be located in your custom code, I'll be closing this ticket following the ticket triaging process. If, after debugging, you find out that this is indeed a bug in Django, please re-open with the specific details and please be sure to include a small Django project to reproduce or a failing test case.

comment:2 by Simon Charette, 4 months ago

Likely related to tickets such as #31973, #32542.

You're likely using Postgres and have the column your JSONField is pointed at defined with a json type instead of jsonb (not something created by Django itself as it never used the json type).

The ORM explicitly request jsonb columns not to be deserialized by the backend (psycopg2 and psycopg) to support the JSONField.decoder feature but since it doesn't use json columns internally it leaves them as is.

Last edited 4 months ago by Simon Charette (previous) (diff)
Note: See TracTickets for help on using tickets.
Back to Top