Your agent's database is a giant JSON blob and you know it

Open your agentic AI Postgres. Look at the agent_runs table. You will probably find a data column of type jsonb with every tool call, every message, every piece of structured data crammed inside. No SQLModel, no typed columns, no schema. It feels fast to write. It is a lie, and the bill comes due the first time you need to query "how many agent runs used the run_bash tool last week?" Your jsonb query is slow, fragile, and breaks whenever the shape of the blob changes.

You did not want this. You wanted a schema. You wanted columns you could index. You wanted the same type you use in your agent code to be the type your database stores. SQLModel gives you all of that for the price of one import.

This post is the pattern, the Pydantic bridge, and the 50 lines that turn a messy jsonb agent store into a type-safe, indexable, migrate-able schema without giving up the ergonomics that made jsonb tempting in the first place.

Why do so many agent projects end up with giant jsonb columns?

Because early in the project you do not know the schema. Every week the agent grows a new tool, a new message type, a new piece of state. Migrating a SQLAlchemy model every time is painful, so you give up and dump everything into a jsonb blob.

That decision made sense on day 1. On day 30 it becomes debt. 3 problems hit at once:

  1. You cannot index into the blob efficiently. Postgres jsonb operators work but they are an order of magnitude slower than a column read, and you lose foreign keys.
  2. You cannot enforce shape. The agent writes "tool": "run_bash" one day and "tool_name": "bash" the next and the column silently accepts both. Every downstream query becomes a parse-and-hope.
  3. You cannot share types with your API. The API returns one shape, the DB stores another, and you wrote a mapping layer nobody maintains.

SQLModel fixes all 3 by making one class the database schema, the Pydantic model, and the API contract. You migrate once. You query with columns. You share the type end to end.

What is SQLModel and how does it bridge Pydantic and SQLAlchemy?

SQLModel is a library by the FastAPI author that unifies Pydantic (for validation) and SQLAlchemy (for persistence) into a single class. You declare fields once with Pydantic syntax. SQLModel generates the database schema from the Pydantic fields and the Pydantic model from the database schema. Same class. Same fields. 2 superpowers.

The mental model: a SQLModel class is a Pydantic BaseModel that happens to know how to persist itself. You can still validate it from JSON, serialize it to JSON, and pass it to FastAPI as a response model. You can also query it with SQLAlchemy select, create tables with SQLModel.metadata.create_all, and add relationships with Relationship().

graph LR
    App[Agent code] -->|one class| SQLModel[SQLModel class]
    SQLModel --> Pydantic[Pydantic validation]
    SQLModel --> SQLA[SQLAlchemy ORM]
    SQLModel --> API[FastAPI response model]
    Pydantic --> JSON[JSON in/out]
    SQLA --> DB[(Postgres)]
    API --> Client[API client]

    style SQLModel fill:#dbeafe,stroke:#1e40af

One class, 4 roles. The savings compound the moment you stop writing mapping code between the DB row, the Pydantic response, and the agent state.

How do you model an agent run with SQLModel?

Start with the core tables: AgentRun, Message, and ToolCall. Each is a SQLModel class with typed fields. Foreign keys use Field(foreign_key=...). Relationships use Relationship().

# filename: models.py
# description: Core schema for an agent run with messages and tool calls.
# One class per table, typed fields, relationships with back_populates.
from datetime import datetime
from typing import Optional
from sqlmodel import SQLModel, Field, Relationship


class AgentRun(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    user_id: str = Field(index=True)
    started_at: datetime = Field(default_factory=datetime.utcnow)
    status: str = Field(default='running', index=True)
    total_tokens: int = 0

    messages: list['Message'] = Relationship(back_populates='run')
    tool_calls: list['ToolCall'] = Relationship(back_populates='run')


class Message(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    run_id: int = Field(foreign_key='agentrun.id', index=True)
    role: str
    content: str
    created_at: datetime = Field(default_factory=datetime.utcnow)

    run: AgentRun = Relationship(back_populates='messages')


class ToolCall(SQLModel, table=True):
    id: Optional[int] = Field(default=None, primary_key=True)
    run_id: int = Field(foreign_key='agentrun.id', index=True)
    tool_name: str = Field(index=True)
    arguments: dict = Field(sa_type=None)
    result: str
    latency_ms: int
    created_at: datetime = Field(default_factory=datetime.utcnow)

    run: AgentRun = Relationship(back_populates='tool_calls')

Notice what is not here: no jsonb blob. The arguments field is still a dict (so the agent can pass anything tool-specific), but every other field is typed and indexed. tool_name has an index so SELECT count(*) FROM toolcall WHERE tool_name = 'run_bash' AND created_at > now() - interval '1 week' is a fast index seek instead of a jsonb scan.

How do you query and insert with SQLModel?

Standard SQLAlchemy Session plus select(). The queries return typed objects so your editor autocompletes the columns.

# filename: queries.py
# description: Create, read, and aggregate agent data with SQLModel.
# Returns typed objects, not rows.
from datetime import datetime, timedelta
from sqlmodel import Session, select, func
from models import AgentRun, ToolCall


def record_tool_call(
    session: Session, run_id: int, tool_name: str, args: dict, result: str, latency_ms: int,
) -> ToolCall:
    call = ToolCall(
        run_id=run_id, tool_name=tool_name, arguments=args,
        result=result, latency_ms=latency_ms,
    )
    session.add(call)
    session.commit()
    session.refresh(call)
    return call


def tool_usage_last_week(session: Session) -> list[tuple[str, int]]:
    cutoff = datetime.utcnow() - timedelta(days=7)
    stmt = (
        select(ToolCall.tool_name, func.count().label('n'))
        .where(ToolCall.created_at > cutoff)
        .group_by(ToolCall.tool_name)
        .order_by(func.count().desc())
    )
    return list(session.exec(stmt))

tool_usage_last_week is the query that was basically impossible with a jsonb blob. It is 4 lines with SQLModel. It runs in milliseconds on an indexed column. It is the moment you realize you should have done this from the start.

For a full walkthrough of how this schema plugs into a production agent stack alongside the event loop and tool design, the Build Your Own Coding Agent course covers it module by module. The free AI Agents Fundamentals primer is the right starting point if the agent loop concept is still new.

How does SQLModel play with FastAPI response models?

This is the killer feature. You pass the SQLModel class directly to FastAPI as a response model. No mapping layer, no separate Pydantic schema, no manual from_orm. The same class that defines the table also defines the JSON shape.

# filename: routes.py
# description: A FastAPI route that returns SQLModel objects as JSON
# without any mapping layer.
from fastapi import FastAPI, Depends
from sqlmodel import Session, select
from models import AgentRun

app = FastAPI()

@app.get('/runs/{run_id}', response_model=AgentRun)
def get_run(run_id: int, session: Session = Depends(get_session)) -> AgentRun:
    run = session.get(AgentRun, run_id)
    if not run:
        raise HTTPException(404, 'run not found')
    return run

That is it. FastAPI reads the Pydantic metadata from the SQLModel class, validates the response, generates the OpenAPI schema, and returns JSON. The database row, the Pydantic model, and the API response are the same type.

When should you still use jsonb inside a SQLModel table?

When the data is genuinely schemaless and you never need to query inside it. Tool arguments are the canonical case: every tool has a different argument shape, you do not want to model each one, and you only ever read the arguments alongside the parent tool call.

The rule: jsonb for the long tail of dynamic fields, typed columns for anything you query or index. A good agent schema has 20 typed columns and 1 or 2 jsonb columns for dynamic shape. A bad agent schema has 3 typed columns and 1 giant jsonb blob that contains everything.

For the deeper picture of how a production agent service layers storage, streaming, and auth together, see the System Design: Building a Production-Ready AI Chatbot post and the FastAPI and Uvicorn for Production Agentic AI Systems deep dive on the serving layer.

What to do Monday morning

  1. Open your agent's DB. If you see a big data or payload jsonb column, count how many distinct shapes live inside it. Each shape wants to become a typed column or its own table.
  2. Install SQLModel and rewrite your 2 most-queried tables as SQLModel classes. Start with agent_runs and whatever table holds tool invocations. Leave the jsonb column in place for backward compatibility during the migration.
  3. Backfill the new typed columns from the jsonb blob with a one-off script. Keep the blob for a week in case you missed a field.
  4. Point FastAPI response models at the SQLModel classes. Delete the separate Pydantic schemas you were maintaining. Delete the mapping code. This is the payoff.
  5. Add indexes on tool_name, user_id, and created_at. Run the tool-usage-last-week query. Watch it return in 3 milliseconds where it used to take 3 seconds.

The headline: SQLModel is one import that collapses your DB model, your Pydantic schema, and your API contract into a single typed class. The jsonb escape hatch stays available for the genuinely dynamic fields. Everything else becomes queryable.

Frequently asked questions

What is SQLModel?

SQLModel is a Python library by the FastAPI author that unifies Pydantic and SQLAlchemy. A SQLModel class is simultaneously a Pydantic model (for validation and JSON serialization) and a SQLAlchemy model (for persistence). You declare fields once and get both superpowers, which removes the mapping layer that used to sit between the database row, the Pydantic schema, and the API response.

Why should agentic AI systems avoid jsonb blob columns?

Because you lose indexability, shape enforcement, and query clarity. Aggregations like "how many tool calls last week" become slow jsonb scans instead of fast index seeks. Shape drift goes undetected because the column accepts anything. And the shape in the blob rarely matches what the API returns, so you end up writing mapping code you cannot maintain. Typed columns fix all 3.

Can I still use jsonb inside a SQLModel table?

Yes, and you should for genuinely dynamic fields. Tool arguments are the canonical case: every tool has a different shape, you never query inside them, and you only read them alongside the parent row. Model the query-relevant fields as typed columns and reserve jsonb for the long tail of dynamic data. A good rule: 20 typed columns and 1 jsonb column, not the reverse.

How does SQLModel work with FastAPI?

Directly. You pass a SQLModel class as a response_model to a FastAPI route and FastAPI reads the Pydantic metadata to validate, serialize, and generate OpenAPI docs. The same class that defines the database table also defines the API response shape, which eliminates the separate Pydantic schemas and mapping code that used to live between the ORM and the API layer.

Is SQLModel production-ready for AI agent workloads?

Yes, with the same caveats as any SQLAlchemy-based stack. It uses SQLAlchemy 2.x under the hood, so connection pooling, migrations with Alembic, and async support all work. For the scale most agent services operate at (tens of thousands of runs per day), it is the pragmatic choice. Above that scale, you might want to shard or move some tables to a dedicated analytics store.

Key takeaways

  1. jsonb blob columns feel fast to write and become slow to query. Every agent project regrets them eventually.
  2. SQLModel unifies Pydantic and SQLAlchemy into one class. Same type for the DB row, the validation model, and the API response.
  3. Query-relevant fields become typed indexed columns. Dynamic tool arguments stay in a jsonb field for shape flexibility.
  4. FastAPI accepts SQLModel classes directly as response models. The mapping layer between DB and API disappears.
  5. Start the migration with your 2 most-queried tables. Backfill the typed columns from the jsonb blob, then delete the blob.
  6. To see SQLModel wired into a full agent stack alongside tool design and the event loop, walk through the Build Your Own Coding Agent course, or start with the AI Agents Fundamentals primer.

For the full SQLModel documentation, tutorials, and advanced relationship patterns, see the official SQLModel docs. The basics in this post map directly onto the tutorial there.

Share this post

Continue Reading

Weekly Bytes of AI

Technical deep-dives for engineers building production AI systems.

Architecture patterns, system design, cost optimization, and real-world case studies. No fluff, just engineering insights.

Unsubscribe anytime. We respect your inbox.

Ready to go deeper?

Go beyond articles. Build production AI systems with hands-on workshops and our intensive AI Bootcamp.