Skip to main content
System Check Lab Ideas that grow in the everyday
Inspiration Space Add Schema Validation Before Model Output Reaches Your Database
Back to Developer Workflow

Add Schema Validation Before Model Output Reaches Your Database

Schema validation between model output and database insertion is essential because structured output from APIs like Gemini is a request, not a guarantee—models can return JSON that parses successfully but violates database constraints like NOT NULL, VARCHAR limits, or foreign keys.

Add Schema Validation Before Model Output Reaches Your Database

The question

A model returns HTTP 200. The response body parses as JSON. Your code does json.loads(response.text) and writes the fields into a database row.

What could go wrong?

This is the single most common pattern in model-backed applications, and it is the single most common place where a model’s non-determinism becomes a data-quality incident. The status code proved the request was accepted. It proved nothing about whether the output is safe to store.

This post is about the validation layer that should sit between “the model returned something” and “the database accepted it” — and why the model’s own structured-output feature is a necessary input to that layer, not a replacement for it.


The short answer

Schema validation before database insertion answers one question: is this output structurally safe to persist, independent of whether it is semantically correct?

It does not answer “is the answer right.” That is a separate gate. It answers a narrower but non-negotiable question: does the output conform to the contract the downstream system depends on — required fields present, types correct, enums within allowed sets, string lengths within column limits, nested arrays not null when the column is NOT NULL.

The reason this cannot be skipped is that structured output on the model side and schema validation on the application side are two different guarantees, and only the second one is under your control.


Structured output is a request, not a promise

The Gemini API documentation describes structured output plainly: you configure the model to generate responses matching a provided JSON schema, which “ensures predictable, type-safe results”. The SDKs let you define that schema with Pydantic in Python or Zod in JavaScript, and the API supports JSON Schema over REST directly.

That capability is real and useful. It shifts the probability distribution toward conformant output. It does not eliminate the tail.

The documentation’s own example shows the pattern that should make an engineer pause:

python

recipe = Recipe.model_validate_json(interaction.output_text)

This call sits inside the example, and it does the right thing — model_validate_json will raise on invalid data. But in production code, the same line is frequently replaced with a plain parse and a database write, because the developer has already declared a response_format and assumes the model will honor it. The model usually does. The word “usually” is doing a lot of work in that sentence.

There are three independent reasons structured output cannot be treated as a storage guarantee:

1. The schema is enforced generation-time, not insert-time. Even a perfectly conformant response must still be checked against the database’s own constraints — column types, NOT NULL, VARCHAR limits, foreign keys, unique constraints. A valid JSON schema and a valid database row are not the same thing.

2. The response can be structurally valid but operationally useless. A well-formed object can arrive with every required field present and every field empty. finish_reason is the field that tells you whether generation completed normally or was truncated; a 200 with a truncated output can still parse if the truncation happens to land on a valid JSON prefix, and schema validation on the partial object will pass if the required keys happen to appear before the cut.

3. The response can be valid JSON that is not valid for your contract. JSON Schema’s own validation vocabulary exists precisely because “parses” and “conforms” are different predicates. A field typed as string in the schema can still be an empty string that violates a downstream business invariant. A field typed as integer can be null if the schema allowed it and the database column did not.

The practical rule: use structured output to make conformant responses likely. Use application-side validation to make non-conformant responses impossible to store.


What actually reaches the database, layer by layer

The validation question becomes concrete when you trace a response through four gates. Each gate catches a different class of failure, and skipping any one of them moves the failure downstream.

Gate

What it checks

Failure if skipped

Parse

Is it valid JSON at all?

json.JSONDecodeError at write time; partial writes

Schema

Required fields, types, enums, nesting

TypeError / IntegrityError at write time; silent type coercion

Domain

Business invariants, ranges, referential integrity

Garbage rows that pass all type checks

Acceptance

finish_reason, refusal state, empty output

Truncated or refused output stored as data

A response_format improves gate 2’s pass rate. It does nothing for gates 1, 3, or 4. And a plain json.loads followed by an insert collapses all four gates into gate 1.

The four failure cases from my earlier success-response analysis map directly onto this table. The empty-but-billed response is a gate 4 failure that passes gates 1 and 2. The silently ignored parameters produce output that passes gates 1 and 2 while violating an assumption the application made. The invisible retry produces a response that passes all four gates but describes the retry layer, not the endpoint. None of these are visible if the only check is “did json.loads succeed.”


The failure cases that justify the layer

Pre-write validation bench with database schema, accepted payload, rejected tray.

The truncated-valid-prefix case. A response hits the per-request output ceiling mid-generation. The truncated body happens to end on a syntactically complete prefix — {"id": 42, "status": "active" is invalid, but {"id": 42, "status": "active", "notes": "" is also invalid, and {"id": 42, "status": "active"} is valid and silently missing every field that would have followed. A parse-and-insert path stores an incomplete record and reports success. A schema gate that requires the missing fields rejects it. The truncation itself is only visible if finish_reason is checked separately.

The silent-coercion case. A field expected to be an integer arrives as the string "42". json.loads does not care. Pydantic in strict mode does. A database driver may coerce it, or may not, depending on the column type and the driver version — which means the same input can produce different stored values across environments. Validation at the boundary makes the coercion decision explicit.

The refusal-as-data case. A safety-blocked response returns a well-formed envelope. The fields your schema expected may be absent, replaced by a refusal object, or present but empty. Without an explicit refusal branch before parsing, the refusal body becomes a stored record. The call stack raised nothing, because nothing raised.

The unversioned-schema case. The response schema changes between model versions — a field renamed, an enum value added, a nested object flattened. A client that validates only by parsing stores whatever shape arrives. A client with a pinned schema rejects the new shape and fails loudly, which is the correct behavior for an unplanned contract change.


Where the validation belongs

The validation belongs at the boundary of the system that owns the data contract, not inside the model client. Three placement rules matter:

Rule 1: Validate before the write, in the same transaction boundary. Validation after the insert means the bad row exists and must be cleaned up. Validation inside the model client means the data-owning service cannot enforce its own contract. The check belongs in the service that will write the row, against the schema that service owns.

Rule 2: Separate structural validation from domain validation. Structural validation answers “does this conform to the declared schema.” Domain validation answers “is this a permissible value for this column in this system.” They fail for different reasons and should produce different errors. Collapsing them into one check makes the error message useless — “validation failed” does not tell you whether to retry the model or reject the input.

Rule 3: Treat validation failure as an expected outcome, not an exception. A validation failure is a data point about the model’s output distribution. If it surfaces as a 500, it becomes an incident. If it surfaces as a typed rejection with the offending payload attached, it becomes a record that can be counted, sampled, and reported. The failure rate is the metric; the exception is just the transport.

A minimal Python pattern that follows these rules:

python

from pydantic import BaseModel, Field, ValidationError
from typing import Literal, Optional

class OrderExtraction(BaseModel):
    order_id: str = Field(min_length=1)
    status: Literal["pending", "confirmed", "cancelled"]
    total_cents: int = Field(ge=0)
    currency: str = Field(pattern=r"^[A-Z]{3}$")
    notes: Optional[str] = Field(default=None, max_length=2000)

def validate_and_stage(raw_text: str, finish_reason: str) -> OrderExtraction:
    # Gate 4: acceptance
    if finish_reason not in ("STOP", "stop"):
        raise RejectedOutput(f"finish_reason={finish_reason!r}")

    # Gate 1 + 2 + 3: parse, schema, domain
    try:
        return OrderExtraction.model_validate_json(raw_text)
    except ValidationError as e:
        raise RejectedOutput(str(e), payload=raw_text) from e

The RejectedOutput path is what makes the failure countable. The payload attachment is what makes the failure diagnosable. Neither is an exception in the sense of “something broke” — both are routine outcomes of a system whose input generator is non-deterministic.


The structured-output nuance worth knowing

The Gemini SDK’s Pydantic integration is convenient enough to encourage a false sense of closure. The documentation shows Recipe.model_json_schema() passed as response_format, followed by Recipe.model_validate_json(interaction.output_text). The same model class is used on both sides of the call.

That symmetry is useful, and it is also the trap. The model-side schema and the database-side schema are the same object only by coincidence. The model schema exists to shape generation. The database schema exists to constrain storage. They overlap, and they are not identical. A field can be optional for generation and required for storage. A string can be unconstrained for generation and VARCHAR(255) for storage. An enum can admit values the application has not yet migrated to handle.

The disciplined version keeps two schemas and one validation step:

  • Generation schema (passed to response_format): permissive enough to let the model express the output, strict enough to reduce the tail.

  • Storage schema (the Pydantic model or SQLAlchemy model validated before write): exactly the contract the database enforces.

  • Validation step: storage schema against the parsed response, with the generation schema’s constraints treated as a hint about what to expect, not as a substitute for the check.

If those two schemas are the same class, that is a deliberate decision, and it should be a deliberate decision — documented, with the cases where they diverge noted. It should not be an accident of reusing the example code.


The record that makes the layer auditable

Schema validation without a record is a try/except that silently drops failures. The minimum per-response record for an auditable validation layer:

json

{
  "logical_request_id": "...",
  "model_id": "gemini-4-argon",
  "finish_reason": "STOP",
  "parse_ok": true,
  "schema_valid": true,
  "domain_valid": false,
  "domain_error": "total_cents violates ge=0",
  "accepted_for_write": false,
  "rejection_reason": "domain",
  "payload_sha256": "...",
  "payload_retained": true
}

Two fields carry most of the diagnostic weight. schema_valid vs. domain_valid separates a model conformance problem from an application invariant problem — the first suggests a prompt or schema adjustment, the second suggests a normalization or mapping bug. rejection_reason as an enum (parse, schema, domain, acceptance) makes the failure distribution countable, and that distribution is the input to the decision of whether the model-side schema needs tightening or the application-side mapping needs fixing.

This is the same record discipline as the reproducibility checklist, applied at the insert boundary: every accepted row has a validated provenance, and every rejected row has a typed reason.


What this layer does not prove

It does not prove the stored data is correct. A row can pass every structural and domain check and still contain a confidently wrong value. Schema validation guards the shape, not the meaning.

It does not prove the model is unreliable. A high validation-failure rate is evidence about the fit between the generation schema, the prompt, and the task — not a verdict on the model.

It does not replace load testing or retry analysis. The validation layer sees the response that reached it; it does not see the requests that never produced one.

What it does prove, when implemented and recorded, is narrower and necessary: nothing reaches the database that did not pass an explicit contract, and every rejection is a counted, typed, retained artifact rather than a swallowed exception. That is the difference between a system that fails visibly and one that stores the failure.

A working demo is evidence. It is not a production system. A 200 with valid JSON is evidence of acceptance. It is not evidence of a storable record.

For AI tool features, rules and availability, refer to their official sites.

Leave your thoughts here, too.

Comments appear after review · up to 500 characters