When asyncpg has a JSONB codec whose encoder is json.dumps, pass Python objects to that codec rather than pre-serializing them. A dictionary encoded once becomes a JSON object; a string containing serialized object text encoded again becomes a JSON string. Diagnose the stored type and writer path before changing readers or indexes. Identical-looking printed values can carry different database semantics.
Trace every serialization boundary
asyncpg's default JSON handling and a connection with custom codecs are different contracts. The driver documentation shows custom JSON conversion with encoder and decoder functions. Inspect the connection setup that actually serves the write, including pool-created connections and maintenance scripts. A writer copied from a default string-based path can be wrong after a codec starts owning serialization.
asyncpg: default type conversion and custom JSON codecs
For a synthetic object {'status':'ready'}, json.dumps produces object JSON text. If the writer first applies json.dumps and the codec applies it again, the wire JSON describes a string containing braces and quotes. A decoder then correctly returns a Python str, not a dict. That is not evidence that the decoder failed; it decoded the stored scalar type accurately.
Executed codec round-trip
Our isolated Python fixture exercised json.dumps/json.loads directly on Python 3.14.7. The native object decoded to dict; the pre-serialized object decoded to str. Arrays, null, ordinary strings, a string containing 42 and malformed container text were also tested. This verifies codec-function semantics only. No asyncpg connection or PostgreSQL storage operation was executed.
value = {'status': 'ready'}
normal = json.loads(json.dumps(value))
double = json.loads(json.dumps(json.dumps(value)))
assert isinstance(normal, dict)
assert isinstance(double, str)In a disposable PostgreSQL lab, inspect jsonb_typeof for the same two writer paths and run the exact operators the application uses. A JSONB string will not behave like an object for path extraction or containment. Capture both the returned Python type and stored JSONB type so a console's escaped rendering cannot become the only diagnostic evidence.
| Input | Decoded type | Repair result |
|---|---|---|
| Native object | dict | Unchanged |
| Pre-serialized object | str | dict |
| Encoded number text | str | Unchanged string |
| Malformed container text | str | Unchanged string |
| All fixture cases | Mixed expected types | Second repair unchanged |
Repair only the container contract you intend
A compatibility reader can inspect an expected-container field and parse a string only if its contents are a valid object or array. The local fixture's repair function leaves existing objects, arrays, null, ordinary text, encoded numbers and invalid container text unchanged. After one repair pass, applying it again produces the same result for every fixture case. That repeat-safety is an actual local observation, not proof of a database migration.
Do not recursively parse arbitrary strings until something changes. A legitimate user string containing JSON can be meaningful text. Restrict repair to reviewed columns with an established object-or-array contract and preserve unusual rows for inspection. Database text restrictions, invalid escapes and heterogeneous historical values can make a broad cast fail. Preview candidate counts and failure categories before applying a bounded migration.
Fix writers before removing compatibility readers
Update every writer to match the chosen codec contract, including jobs, admin scripts and bulk imports. Then inventory existing values by type, perform a reviewed repair and verify row counts plus application reads. Readers may need temporary compatibility during a rolling deployment. Remove that branch only after the supported writer versions and historical shapes are accounted for; otherwise an old worker can recreate the condition after cleanup.
The inspected code and migration provide implementation context for serialization ownership, not a new claim about deployed data. This article publishes only synthetic values and general repair decisions. Before an actual data change, use an approved backup and rollback plan, test database operators on representative fixtures and retain the migration's real results. The strongest diagnostic question remains simple: which layer owns serialization, and what type did the database actually store?
LLM structured output validation
Building UltraFit360: the app, coach portal and shared backend
Bring one API or data workflow and a sanitized failure example for a bounded implementation assessment.
Why can asyncpg JSONB arrive as a string instead of an object?
A custom JSON encoder may have serialized already serialized text, storing a JSON string. Inspect codec ownership and the stored JSONB type before repairing expected containers.
Is a returned string always a decoder bug?
No. A decoder correctly returns a string when the stored JSON value is a string scalar.
Should every JSON-looking string be parsed repeatedly?
No. Restrict compatibility repair to reviewed fields with an expected container contract and leave legitimate scalar text unchanged.
