By Sagar Shankaran, Founder of CallSphere
Master PostgreSQL JSONB for storing variable-structure agent outputs including tool call results, LLM responses, and agent metadata with proper indexing, partial updates, and query optimization.
Key takeaways
AI agent systems produce heterogeneous data. A weather tool returns temperature and humidity. A search tool returns ranked results with snippets. A database tool returns rows with different column sets. Trying to force all of these into rigid relational columns creates an explosion of nullable columns or an unmaintainable table-per-tool design.
PostgreSQL JSONB solves this. It stores JSON as a decomposed binary format that supports indexing, partial updates, and rich query operators. You get the flexibility of a document store with the transactional guarantees and query power of PostgreSQL.
Here is a practical schema for an agent system that stores tool results in JSONB:
flowchart LR
INPUT(["User intent"])
PARSE["Parse plus<br/>classify"]
PLAN["Plan and tool<br/>selection"]
AGENT["Agent loop<br/>LLM plus tools"]
GUARD{"Guardrails<br/>and policy"}
EXEC["Execute and<br/>verify result"]
OBS[("Trace and metrics")]
OUT(["Outcome plus<br/>next action"])
INPUT --> PARSE --> PLAN --> AGENT --> GUARD
GUARD -->|Pass| EXEC --> OUT
GUARD -->|Fail| AGENT
AGENT --> OBS
style AGENT fill:#4f46e5,stroke:#4338ca,color:#fff
style GUARD fill:#f59e0b,stroke:#d97706,color:#1f2937
style OBS fill:#ede9fe,stroke:#7c3aed,color:#1e1b4b
style OUT fill:#059669,stroke:#047857,color:#fff
CREATE TABLE tool_executions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id UUID NOT NULL REFERENCES conversations(id),
tool_name TEXT NOT NULL,
input_args JSONB NOT NULL DEFAULT '{}',
output_data JSONB,
error_detail JSONB,
execution_ms INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Each tool writes its own structure into output_data. A weather tool might store {"temp_f": 72, "humidity": 45, "condition": "sunny"} while a search tool stores {"results": [{"title": "...", "url": "...", "snippet": "..."}], "total_hits": 1250}.
PostgreSQL provides operators for navigating JSONB structures. The most important ones for agent data:
Hear it before you finish reading
Talk to a live CallSphere AI voice agent in your browser — 60 seconds, no signup.
-- Extract a text value from a JSONB column
SELECT output_data->>'condition' AS weather_condition
FROM tool_executions
WHERE tool_name = 'get_weather';
-- Filter by a nested JSONB value
SELECT *
FROM tool_executions
WHERE tool_name = 'search'
AND (output_data->>'total_hits')::int > 100;
-- Check if a key exists
SELECT *
FROM tool_executions
WHERE output_data ? 'error_code';
-- Query nested arrays using jsonb_array_elements
SELECT
te.id,
result->>'title' AS result_title,
result->>'url' AS result_url
FROM tool_executions te,
jsonb_array_elements(te.output_data->'results') AS result
WHERE te.tool_name = 'search';
The ->> operator returns text, while -> returns JSONB. This distinction matters when comparing values or casting types.
Without indexes, every JSONB query requires a full table scan. PostgreSQL offers two JSONB index types:
GIN Index — supports containment and existence operators:
-- General-purpose GIN index on the entire JSONB column
CREATE INDEX idx_tool_exec_output_gin
ON tool_executions USING gin(output_data);
-- Now these queries use the index:
SELECT * FROM tool_executions
WHERE output_data @> '{"condition": "sunny"}';
SELECT * FROM tool_executions
WHERE output_data ? 'error_code';
Expression Index — for frequently queried specific keys:
-- Index a specific extracted value
CREATE INDEX idx_tool_exec_total_hits
ON tool_executions ((output_data->>'total_hits'));
-- This query now uses a B-tree scan:
SELECT * FROM tool_executions
WHERE output_data->>'total_hits' = '1250';
Use GIN indexes when you query many different keys. Use expression indexes when you repeatedly filter on the same key. For large tables, the jsonb_path_ops GIN operator class is smaller and faster for containment queries:
CREATE INDEX idx_tool_exec_output_pathops
ON tool_executions USING gin(output_data jsonb_path_ops);
Updating a single key inside a JSONB column without rewriting the entire document:
Still reading? Stop comparing — try CallSphere live.
CallSphere ships complete AI voice agents per industry — 14 tools for healthcare, 10 agents for real estate, 4 specialists for salons. See how it actually handles a call before you book a demo.
import asyncpg
async def mark_output_reviewed(pool, execution_id: str):
await pool.execute(
"""
UPDATE tool_executions
SET output_data = jsonb_set(
output_data,
'{reviewed}',
'true'::jsonb
)
WHERE id = $1
""",
execution_id,
)
The jsonb_set function takes the column, a path array, and the new value. It returns a new JSONB document with just that key changed. For deeply nested updates:
UPDATE tool_executions
SET output_data = jsonb_set(
output_data,
'{results,0,processed}',
'true'::jsonb
)
WHERE id = $1;
JSONB is not a replacement for proper relational columns. If you filter, sort, or join on a value in every query, it belongs in its own column. A good rule: start with JSONB for new, evolving data structures. Once a field stabilizes and appears in WHERE clauses frequently, promote it to a dedicated column.
Also avoid storing large arrays (thousands of elements) in a single JSONB cell. PostgreSQL rewrites the entire JSONB value on any update, so large documents cause write amplification.
JSON stores the raw text exactly as inserted, preserving whitespace and duplicate keys. JSONB parses the input into a binary format, removing duplicates and whitespace. JSONB is almost always the correct choice because it supports indexing, is faster to query, and uses less storage after the initial parse cost.
PostgreSQL does not enforce JSONB schemas natively. Use CHECK constraints for simple validation: CHECK (output_data ? 'status') ensures a key exists. For complex validation, enforce structure at the application layer with Pydantic models or Zod schemas before inserting, and use database triggers for critical invariants.
Yes. SQLAlchemy maps JSONB to Python dictionaries natively via sqlalchemy.dialects.postgresql.JSONB. Prisma supports JSONB through its Json type, allowing you to read and write JSON objects directly. Both ORMs generate correct queries for JSONB operators.
#PostgreSQL #JSONB #Database #AIAgents #DataStorage #AgenticAI #LearnAI #AIEngineering

Written by
Sagar Shankaran· Founder, CallSphere
LinkedInSagar Shankaran is the founder of CallSphere, where he builds production AI voice and chat agents deployed across healthcare, hospitality, real estate, and home services. He writes about agentic AI, LLM engineering, and shipping voice agents that handle real calls in production.
See how AI voice agents work for your industry. Live demo available -- no signup required.
A founder's guide to the personal AI assistant market: best AI assistant apps, business-grade options, and how CallSphere's voice agent fits in.
A founder's guide to free AI agents, low-code AI agent builders, and how to know when you should pay for a real platform like CallSphere.
Graphiti is the open-source temporal knowledge graph for AI agents in 2026. Learn how bi-temporal memory beats vector RAG for voice agents and long-running LLMs.
Chatbot app vs ChatGPT in 2026: a founder's clear take on the difference, when to use which, and how a real AI chatbot app development works.
How we built a fault-tolerant HVAC emergency triage and tech-dispatch platform on Kubernetes — three-tier CQRS, 11 micro-agents on the OpenAI Agents SDK + LangGraph, NATS JetStream, DTMF/SMS/WebSocket acceptance, circuit breakers, and an evaluation pipeline that catches regressions before they wake a tech at 3 AM.
Head-to-head: OpenAI Frontier and Anthropic's managed agent stack — strengths, fit, and what each means for enterprise AI voice and chat deployment.
© 2026 CallSphere Inc. All rights reserved.
Made within San Francisco
Watch how CallSphere handles real customer calls, schedules appointments, and processes payments — live.