2025, Oct 17 15:00
Fixing asyncpg JSONB: register a jsonb codec to get native Python dicts and lists instead of strings
Learn why asyncpg returns PostgreSQL JSONB as strings and how to fix it: register a jsonb codec via set_type_codec to decode to native Python objects now.
When working with asyncpg and PostgreSQL’s JSONB, a common surprise is getting plain strings back instead of native Python objects. If your queries return JSONB values as text, operations that rely on dicts and lists become awkward, and type assumptions break instantly. Below is a concise walkthrough of the issue and a minimal, reliable fix.
Reproducing the issue
The following minimal script selects a JSONB literal and prints the result. The output shows that the JSONB value is received as a string.
import asyncio
import asyncpg
async def runner():
    link = await asyncpg.connect("postgres://user:pass@localhost/database")
    rows = await link.fetch("select '[1, 2, 3]'::jsonb as col;")
    for rec in rows:
        for k, v in rec.items():
            print("'" + k + "'")
            print("'" + v + "'")
if __name__ == "__main__":
    asyncio.run(runner())
Expected a list, but saw a quoted string in the output. Introspection confirms the value is of type str.
What’s happening and why
To get automatic conversion of JSONB to native Python types in asyncpg, you need to register a type codec for jsonb. Without that explicit codec, the value is returned as text in this scenario. The behavior aligns with guidance discussed in community Q&A and the library’s own example for automatic JSON conversion, which demonstrate the need to call set_type_codec for jsonb.
To automatically decode jsonb values to Python objects with asyncpg, you need to explicitly register a custom type codec.
Fix: register a jsonb codec
Registering a codec tells asyncpg how to serialize and deserialize JSONB. Using json.dumps and json.loads is enough for round-tripping between PostgreSQL and Python data structures.
import asyncio
import asyncpg
import json
async def run_fixed():
    dbh = await asyncpg.connect("postgres://user:pass@localhost/database")
    await dbh.set_type_codec(
        'jsonb',
        encoder=json.dumps,
        decoder=json.loads,
        schema='pg_catalog'
    )
    result = await dbh.fetch("select '[1, 2, 3]'::jsonb as col;")
    for rec in result:
        parsed = rec['col']
        print(parsed, type(parsed))  # Output: [1, 2, 3] <class 'list'>
    await dbh.close()
if __name__ == "__main__":
    asyncio.run(run_fixed())
After adding set_type_codec, JSONB values arrive as proper Python dicts and lists, so downstream code can work with them directly.
Why this matters
Consistent types are crucial when building data pipelines, writing validation logic, or composing queries that feed application code. Receiving JSONB as strings forces ad‑hoc parsing, increases the risk of subtle bugs, and scatters conversion logic throughout the codebase. Centralizing the behavior at the connection layer keeps your data model predictable and your code simpler.
Takeaways
If you expect native Python objects for JSONB with asyncpg, explicitly register a jsonb codec using set_type_codec with json.dumps and json.loads. Verify the resulting types early, and keep the conversion policy close to the connection setup. This small step prevents type mismatches and keeps your I/O boundary clean.
The article is based on a question from StackOverflow by CoderFF and an answer by Preston Johnson.