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.

Sharp edges and limits#

The CLI does not go through Pydantic at all. tdw analyze (cli/analyze.py) imports SchemaInfo directly from core.models and calls SchemaAnalyzer itself; it never builds, and never needs, a SchemaAnalysisRequest or SchemaAnalysisResponse. Since the CLI is the project’s primary interface, the runtime-enforcement guarantee this page describes covers only the optional HTTP path; most uses of this tool never touch it.

The dataclasses on the other side of that boundary carry no equivalent guarantee of their own. A dataclass’s generated __init__ does not check its annotations at construction; the same erasure of type hints at runtime that motivates Pydantic in the first place applies just as much to a plain dataclass. Column(name=123, data_type=None) constructs without complaint. That split is a deliberate, documented tradeoff (see Python), not an oversight, but it does mean “Pydantic validates the data” is true of exactly one file in this codebase, not of the system as a whole.

Several fields inside api/models.py are typed more loosely than their own descriptions imply. GeneratorCreationRequest.skill_level, DataGenerationRequest.format, RapidDeploymentRequest.output_preference, and ContributionValidationRequest.contribution_type are all plain str, even though their descriptions or worked examples name a closed set of values (“beginner”/”intermediate”/”advanced”, “json”/”csv”/”sql”, “minimal”/”standard”/”comprehensive”). None of them use Literal[...] or an Enum the way core/models.py uses EntityType for a comparable closed vocabulary; a request with "format": "xml" passes model validation cleanly.

A handful of fields are typed Dict[str, Any] or List[Dict[str, Any]]: SchemaAnalysisResponse.analysis_metadata, HealthCheckResponse.system_info, TeamStatusResponse.recent_activity. Pydantic confirms the outer shape and stops there; nothing checks what is inside. That is a reasonable choice for genuinely free-form metadata, but it also means the OpenAPI schema cannot describe those fields’ actual contents.

None of the numeric fields carry a range constraint. DatabaseConnection.timeout, SchemaAnalysisRequest.max_tables, and RapidDeploymentRequest.target_time are plain int/Optional[int] with a default and nothing else. Pydantic 2.5 supports Field(gt=0) and similar bounds, and this codebase does not use them on any of these three, so a timeout of -30 or a target_time of 0 both validate without complaint.

Finally, as covered above, several path operations return a bare dict with no response_model at all. For those routes none of Pydantic’s guarantees, request or response, apply; they are ordinary FastAPI JSON responses that happen to sit next to Pydantic-backed ones in the same file.

Learn more resources#

Official documentation

  • Models: how BaseModel subclasses and field types are declared, on the docs version matching this project’s pydantic>=2.5.0 pin.

  • Fields: the full Field() API, including the ge/gt/le/lt numeric constraints this project’s models do not use.

  • Alias: the mechanism behind DataGenerationRequest’s alias="validate" and populate_by_name.

  • Configuration: ConfigDict options, including json_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-core rewrite in Rust and what changed for users moving from v1.

Videos