CURRENT TREND INSIGHT
How to design structured database schemas for storing unstructured model logs Illustration

How to design structured database schemas for storing unstructured model logs

Direct Summary:

The practical schema for LLM interaction logs is a hybrid: fixed, indexed columns (timestamp, model name, user/session ID, latency, token counts, cost) for the fields you'll actually filter and aggregate on, plus one JSONB column for the variable payload — the full prompt, response, and any tool calls, which don't have a consistent shape across different request types. Don't force the variable, per-request-type content into rigid columns, and don't dump everything into JSON either — the fields you query on constantly belong outside the JSON blob.

"Any sufficiently advanced technology is indistinguishable from magic."

— Arthur C. Clarke

Key Insights

  • JSONB isn't a substitute for schema design: PostgreSQL's own documentation and community best practice both note that even flexible JSON documents benefit from a "somewhat fixed" internal structure — JSONB gives you flexibility, not a reason to skip planning.
  • Hot query fields belong in real columns: anything central to your actual query patterns (filtering by model, aggregating cost by day, searching by user) should be a normal indexed column, not buried inside a JSONB blob.
  • Watch update-lock granularity: PostgreSQL locks the whole row on any update, so a frequently-changing field nested inside a large JSONB document can cost more in lock contention than expected — keep fast-changing fields as separate columns.

LLM interaction logs are a natural JSONB use case because different request types genuinely have different shapes — a simple completion, a tool-calling turn, and a multi-step agent trace don't fit the same rigid table structure. But "the payload varies" doesn't mean "give up on schema entirely" — the fields you'll actually filter, group by, and index on should still be real columns, with JSONB reserved for the part that's genuinely variable.

Structuring the hybrid schema

1. Put frequently-queried fields in real columns. Timestamp, model name, user/session ID, token counts, and cost are things you'll filter and aggregate on constantly — indexed native columns handle this far better than reaching into a JSON blob for every query.

2. Put the variable request/response payload in one JSONB column. The actual prompt, response text, and any tool-call arguments differ in shape between a plain completion and a multi-tool agent turn — JSONB accommodates that without a table-per-request-type explosion.

3. Add a GIN index on the JSONB column if you need to query inside it. If you occasionally need to search within the payload (e.g., find all logs where a specific tool was called), a GIN index makes that practical instead of a full-table scan.

llm_logs_schema.sql
CREATE TABLE llm_logs (
    id BIGSERIAL PRIMARY KEY,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    model TEXT NOT NULL,
    session_id TEXT,
    input_tokens INTEGER,
    output_tokens INTEGER,
    cost_usd NUMERIC(10, 6),
    -- Variable-shape payload: prompt, response, tool calls
    payload JSONB NOT NULL
);

CREATE INDEX idx_llm_logs_model_time ON llm_logs (model, created_at);
CREATE INDEX idx_llm_logs_payload_gin ON llm_logs USING GIN (payload);
Approach Query Performance Flexibility
All fields in JSONB, no real columns Poor for common filters/aggregations without expression indexes Maximum, but hard to query efficiently
Rigid columns for everything Fast, but breaks or needs migration when a new request shape appears None — every new field needs a schema change
Hybrid: real columns + one JSONB payload column Fast for common queries, GIN-indexable for payload search High — payload shape can vary freely per request type

The hybrid pattern isn't a compromise so much as matching each field to what it actually needs: structure and speed for the handful of fields every query touches, and flexibility for the payload that genuinely differs by request type. Revisit which fields belong in real columns as your query patterns solidify — a field that starts inside JSONB can graduate to its own column once you know you'll filter on it often.

Practical Challenge

Create the table above, insert a few rows with different payload shapes (a plain completion vs. one with tool calls), then write a query using the GIN index to find all logs where the payload contains a specific tool name.

Concept Check

In a hybrid schema for LLM interaction logs, which fields should be real (non-JSONB) columns?
Correct! Fields central to your actual query patterns belong as indexed native columns; the genuinely variable payload belongs in JSONB.
Incorrect. Try again! The rule of thumb is: hot query fields as real columns, variable-shape payload as JSONB — not all-or-nothing in either direction.

Sources & Further Reading

Previous Guide Dashboard Next Guide