Pydantic#
Test Data Workbench’s primary interface is the tdw command line tool; the
FastAPI layer described in FastAPI is an optional second interface
that lets a team member run the same schema-analysis and generation
operations over HTTP. The moment an operation is reachable over HTTP, its
input is no longer something the program controls: a JSON body can be
missing fields, hold the wrong type, or simply be malformed. Pydantic is the
library that turns that untrusted JSON into a checked Python object, or a
clear rejection, before any of the project’s own code runs against it.
pyproject.toml pins pydantic>=2.5.0, and it is a narrow dependency
here. Only one file in this codebase imports it:
src/test_data_workbench/api/models.py. Pydantic’s job is confined to
describing what a request into, and a response out of, the FastAPI app looks
like; it is not a general-purpose data layer for the project.
The idea underneath#
The concept behind Pydantic has a name: parse, don’t validate, from Alexis King’s 2019 essay of the same title. A validator looks at a value and answers a yes/no question, “is this shaped correctly”, then hands the caller back the exact same loosely typed value it was given. A parser looks at the same value and returns something new and more specific, or explains why it could not; from that point on the caller holds a value whose type already proves the properties that mattered. King’s argument is that pushing this parsing step to the boundary, and carrying the resulting trusted type inward rather than re-checking loosely typed data at every function along the way, is what makes illegal states unrepresentable elsewhere in the program.
That is the shape of a Pydantic BaseModel. Constructing a
SchemaAnalysisRequest from a request body either returns an object an
endpoint function can use without another type check, or raises a
ValidationError before the endpoint body runs at all; nothing downstream
has to ask whether request.connection.connection_string is really a
string again.
There is a second, more mechanical idea worth naming alongside it. Python
type hints (str, Optional[int], and the rest) are, by design, not
enforced by the interpreter. PEP 484, which introduced them, is explicit that
they exist for static analysis: a tool such as mypy reads the annotations and
reasons about the program before it runs, while the interpreter itself
ignores them completely at runtime. A function annotated
def f(x: int) will run f("not an int") without complaint; nothing in
the language stops it. Pydantic’s contribution is closing exactly that gap
for the boundary it owns: it reads the same annotations a type checker would,
but enforces them the moment a model is constructed, coercing what it safely
can and raising a structured error for the rest. A static checker’s
guarantee is about the code as written, and only holds if every caller was
also checked; a runtime parse is a guarantee about the actual value that
arrived, checked once, at the moment of construction, regardless of where the
data came from.
Pydantic v2 moved that runtime enforcement into a separate package written in
Rust, pydantic-core, called from the Python BaseModel layer. The
BaseModel, Field, and ConfigDict API this project uses is the
Python surface over that engine.
How it fits this project#
src/test_data_workbench/api/models.py defines one BaseModel
subclass per request or response shape the FastAPI app accepts or returns:
DatabaseConnection, SchemaAnalysisRequest/SchemaAnalysisResponse,
GeneratorCreationRequest/GeneratorCreationResponse,
TeamTemplate/TeamTemplatesResponse,
DataGenerationRequest/DataGenerationResponse,
ContributionValidationRequest/ContributionValidationResponse,
HealthCheckResponse, RapidDeploymentRequest/RapidDeploymentResponse,
and TeamStatusResponse.
The rest of the system, core and adaptation, is built on plain
@dataclass types instead (EntityType and ConstraintType are plain
Enum classes); see Python for that side of the split. Pydantic
sits at exactly one seam: a request arrives as a BaseModel, an endpoint
function calls into dataclass-based core code, and the result is packaged
back into a BaseModel on the way out. The tdw CLI, the project’s
primary interface, never crosses that seam.
Model definitions and field types#
Every model in api/models.py is a BaseModel subclass and uses the
same typing module spellings the rest of the codebase favors (see
Python): Optional[int], Dict[str, Any], List[str], and so
on, rather than the newer | union syntax. SchemaAnalysisRequest shows
the common pattern, including composing one model inside another:
class SchemaAnalysisRequest(BaseModel):
"""Request for schema analysis."""
connection: DatabaseConnection
rapid_mode: bool = Field(True, description="Use rapid analysis for speed")
max_tables: Optional[int] = Field(20, description="Maximum tables to analyze")
Field(...), the Ellipsis as first positional argument, marks a field
required; a concrete default, as on rapid_mode and max_tables, makes
it optional. The description on each field is not decoration: it is what
populates the parameter descriptions FastAPI writes into the generated
OpenAPI schema, and therefore what a reader sees in /docs. Response
models mostly skip Field() and rely on bare annotations
(success: bool, tables_found: int), since a response is constructed
by the project’s own code rather than parsed from an untrusted request; there
is nothing there to default or coerce.
ConfigDict and the json_schema_extra example#
Every model that needs configuration sets it with the v2
model_config = ConfigDict(...) class attribute, not the v1-style nested
class Config:. In this codebase that configuration is used for one thing:
attaching a worked example to the schema.
class DatabaseConnection(BaseModel):
"""Database connection information."""
model_config = ConfigDict(json_schema_extra={
"examples": [{
"connection_string": "postgresql://user:pass@localhost:5432/dbname",
"timeout": 30,
}]
})
connection_string: str = Field(..., description="Database connection string")
timeout: Optional[int] = Field(30, description="Connection timeout in seconds")
Because FastAPI builds its OpenAPI document straight from these models, that
example shows up directly in the /docs Swagger UI without any separate
documentation-writing step. It lives in the same file, next to the field
definitions it illustrates, so a change to the shape and a change to its
example are one edit rather than two that can drift apart.
Aliasing around a name collision#
DataGenerationRequest has one field worth reading closely:
class DataGenerationRequest(BaseModel):
"""Request for test data generation."""
model_config = ConfigDict(populate_by_name=True, json_schema_extra={...})
scenario: str = Field("testing", description="Business scenario to generate")
entities: Optional[Dict[str, int]] = Field(None, description="Custom entity counts")
format: str = Field("json", description="Output format: json, csv, sql")
validate_output: bool = Field(True, description="Validate generated data", alias="validate")
The Python attribute is validate_output; the wire field, what a client
actually sends and what appears in the OpenAPI schema, is validate, via
alias="validate". validate reads naturally from an API consumer’s
side, but the bare word is already overloaded inside this codebase:
src/test_data_workbench/core/validators.py defines SchemaValidator,
GeneratorValidator, and TeamContributionValidator, all built around a
shared ValidationResult dataclass (see Python), and
SchemaValidator.validate_generated_data is, in fact, the check this flag
turns on or off. Naming the Pydantic attribute validate_output instead of
validate keeps that one boolean from colliding, in name only, with the
broader concept of validating a team contribution. populate_by_name=True
is what still allows the model to be built from Python code using the
attribute name validate_output directly, rather than only accepting the
alias; without it, only validate= would work as a constructor keyword.
This is v2 vocabulary throughout: the v1 equivalent of populate_by_name
was named allow_population_by_field_name, one of several renames a
reader coming from older Pydantic material will run into.
How FastAPI turns these models into the OpenAPI schema#
Most path operations declare a response_model:
@router.post("/analyze", response_model=SchemaAnalysisResponse)
async def analyze_production_schema(request: SchemaAnalysisRequest,
background_tasks: BackgroundTasks):
FastAPI reads the request parameter’s annotation and the response_model
for every router it mounts, and builds the OpenAPI document from those two
pieces, the same document /docs renders as Swagger UI (see
FastAPI). This is also where the json_schema_extra examples set
through ConfigDict surface: FastAPI reads them off the model’s
configuration and attaches them to the matching operation in the schema.
Not every path operation gives it a model to read. DELETE
/v4/schema/analyze/cache and GET /v4/schema/status in schema.py;
GET /v4/team/dashboard in team.py; and GET
/v4/deployment/rapid/status, GET /v4/deployment/rapid/{deployment_id},
and POST /v4/deployment/rapid/benchmark in deployment.py all return a
plain dict literal with no response_model= argument at all. The root
endpoint in api/main.py goes partway there, declaring
response_model=dict, which documents “a JSON object” and nothing more
specific. For all of these routes, /docs has no concrete response schema
to show, and nothing validates the shape of what actually goes out.
Validation error responses#
api/main.py registers exception handlers for 404 and 500, each
returning a custom JSON body with an error key and a suggestion, but
nothing in the project intercepts a Pydantic validation failure. When a
request body fails to build one of these models, for example a POST
/v4/schema/analyze with no connection key, or a connection_string
that is not a string, FastAPI’s default handling applies unmodified: an HTTP
422 response shaped like this, one entry in detail per failing
field, generated by pydantic-core:
{
"detail": [
{
"type": "missing",
"loc": ["body", "connection"],
"msg": "Field required",
"input": {}
}
]
}
That is a v2-shaped error: the type, loc, msg, and input keys
(and, in the full response, a url pointing at Pydantic’s per-error-code
documentation) are more structured than Pydantic v1’s plainer error
dictionaries. It is also a different failure mode from the
success: False responses described in FastAPI: a 422 happens
before an endpoint function’s body runs at all, because the request never
became a valid model in the first place, while a success: False
SchemaAnalysisResponse happens after the request validated fine but the
analysis itself failed.
The boundary between API models and internal models#
Nothing constructed inside core or adaptation is a Pydantic model.
SchemaInfo, Table, Column, Relationship, and the rest of
core/models.py are the @dataclass types described in Python.
An endpoint function is where the translation happens in both directions. In
schema.py, analyze_production_schema receives an already validated
SchemaAnalysisRequest, calls SchemaAnalyzer.analyze_production_schema
(which returns a dataclass SchemaInfo), folds schema_info.tables into
a plain dict of counts, and only then builds a SchemaAnalysisResponse
to return:
entity_type_counts = {}
for table in schema_info.tables:
entity_type = table.entity_type.value
entity_type_counts[entity_type] = entity_type_counts.get(entity_type, 0) + 1
response = SchemaAnalysisResponse(
success=True,
database_name=schema_info.database_name,
tables_found=len(schema_info.tables),
entity_types=entity_type_counts,
relationships_detected=len(schema_info.relationships),
analysis_time=schema_info.analysis_metadata.get('analysis_time', 0.0),
analysis_metadata=schema_info.analysis_metadata
)
Keeping that boundary sharp means Pydantic’s validation and JSON-schema machinery is paid for exactly once, at the edge, rather than threaded through the analyzer and generator code that does the actual work.
Learn more resources#
Official documentation
Models: how
BaseModelsubclasses and field types are declared, on the docs version matching this project’spydantic>=2.5.0pin.Fields: the full
Field()API, including thege/gt/le/ltnumeric constraints this project’s models do not use.Alias: the mechanism behind
DataGenerationRequest’salias="validate"andpopulate_by_name.Configuration:
ConfigDictoptions, includingjson_schema_extra.FastAPI: Handling Errors: the default
RequestValidationError/422 behavior this project relies on without overriding.FastAPI: Response Model: how
response_model=drives both response validation and the generated OpenAPI schema.PEP 484: the type hint specification Pydantic enforces at runtime instead of leaving to a static checker.
Tutorials and blogs
Parse, don’t validate: Alexis King’s essay naming the pattern this page’s “The idea underneath” section describes.
Announcement: Pydantic V2 Release: Samuel Colvin and Terrence Dorsey’s account of the
pydantic-corerewrite in Rust and what changed for users moving from v1.
Videos
Samuel Colvin - Garbage in → Pydantic → you’re golden! Pycon 2023 (PyCon Lithuania recording): a talk from Pydantic’s creator on the same parse-at-the-boundary idea this page covers.