Schema Diagram — Class Boxes for Data, Not Behavior
A schema diagram is a class-box variant that drops the methods section. It treats a Python class purely as a data shape — useful for documenting dataclasses, Pydantic models, TypedDicts, NamedTuples, ORM tables, and API payloads. When Python developers say "this class," they usually mean "this shape" — schema diagrams capture exactly that, without inventing object-behavior structure that isn't really there.
1. @dataclass and Pydantic Models
The two most common schema wrappers in modern Python: @dataclass from the standard library, and pydantic.BaseModel from Pydantic. Both produce data-shape classes with type-annotated fields. Diagram them as 2-section boxes (header + fields) with no methods section.
┌──────────────────────────────────────────────────────────────────────────────────────────┐ │ Schema Diagram — @dataclass and Pydantic (Schema Documentation) │ │ │ │ ┌────────────────────────────────┐ ┌────────────────────────────────┐ │ │ │ <<dataclass>> │ │ <<pydantic.BaseModel>> │ │ │ │ Order │ │ OrderRequest │ │ │ ├────────────────────────────────┤ ├────────────────────────────────┤ │ │ │ order_id: int │ │ customer_id: int = Field(gt=0) │ │ │ │ customer_id: int │ │ items: list[ItemSpec] │ │ │ │ date: datetime │ │ shipping: AddressDTO | None │ │ │ │ items: list[OrderItem] │ │ currency: Currency = "USD" │ │ │ │ total: Decimal │ └────────────────────────────────┘ │ │ │ status: OrderStatus │ │ │ └────────────────────────────────┘ │ │ │ │ Schema diagrams differ from class diagrams: NO methods section. │ │ Show field names + types only — these are DATA SHAPES, not behavior. │ │ Stereotypes mark the wrapper: <<dataclass>>, <<pydantic>>, │ │ <<TypedDict>>, <<NamedTuple>>. │ └──────────────────────────────────────────────────────────────────────────────────────────┘
Source code for the diagram above
from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from enum import Enum
from pydantic import BaseModel, Field
# Internal domain object
@dataclass(frozen=True, slots=True)
class Order:
order_id: int
customer_id: int
date: datetime
items: list["OrderItem"]
total: Decimal
status: "OrderStatus"
# External-facing API request
class OrderRequest(BaseModel):
customer_id: int = Field(gt=0)
items: list["ItemSpec"]
shipping: "AddressDTO | None" = None
currency: "Currency" = "USD"
The @dataclass + Pydantic combo is the standard pattern for FastAPI / clean architecture: Pydantic at the boundary (validation), dataclasses inside (domain). The schema diagram makes both visible without pretending they have rich behavior.
2. SQLAlchemy ORM as ER Diagram
ORM models in Django and SQLAlchemy are schemas with persistence. They map to database tables; the foreign keys map to relationships. The right diagram is an entity-relationship diagram, not a class diagram — the methods (save(), delete()) are framework boilerplate and don't add information.
┌──────────────────────────────────────────────────────────────────────────────────────────┐ │ Schema Diagram — SQLAlchemy ORM (relationships drawn as ER) │ │ │ │ ┌────────────────────────────┐ │ │ │ <<Table>> │ │ │ │ Customer │ │ │ ├────────────────────────────┤ │ │ │ id: Mapped[int] PK │ │ │ │ email: Mapped[str] unique │ │ │ │ name: Mapped[str] │ │ │ │ created: Mapped[datetime] │ │ │ └────────────────────────────┘ │ │ │ │ │ ▼ has many (1 → *) │ │ ┌────────────────────────────┐ │ │ │ <<Table>> │ │ │ │ Order │ │ │ ├────────────────────────────┤ │ │ │ id: Mapped[int] PK │ │ │ │ customer_id: FK → Customer │ │ │ │ date: Mapped[datetime] │ │ │ │ total: Mapped[Decimal] │ │ │ └────────────────────────────┘ │ │ │ │ │ ▼ contains (1 → *) │ │ ┌────────────────────────────┐ │ │ │ <<Table>> │ │ │ │ OrderItem │ │ │ ├────────────────────────────┤ │ │ │ id: Mapped[int] PK │ │ │ │ order_id: FK → Order │ │ │ │ product_id: FK → Product │ │ │ │ quantity: Mapped[int] │ │ │ └────────────────────────────┘ │ │ │ │ ORM models ARE schema. Each box is a Python class but functions as a │ │ table. FK columns become arrows. relationship() pairs become labels. │ │ Drawing methods would clutter — the BEHAVIOR lives in service modules. │ └──────────────────────────────────────────────────────────────────────────────────────────┘
Equivalent SQLAlchemy 2.x code
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from datetime import datetime
from decimal import Decimal
class Base(DeclarativeBase):
pass
class Customer(Base):
__tablename__ = "customer"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True)
name: Mapped[str]
created: Mapped[datetime]
orders: Mapped[list["Order"]] = relationship(back_populates="customer")
class Order(Base):
__tablename__ = "order"
id: Mapped[int] = mapped_column(primary_key=True)
customer_id: Mapped[int] = mapped_column(ForeignKey("customer.id"))
date: Mapped[datetime]
total: Mapped[Decimal]
customer: Mapped["Customer"] = relationship(back_populates="orders")
items: Mapped[list["OrderItem"]] = relationship(back_populates="order")
class OrderItem(Base):
__tablename__ = "order_item"
id: Mapped[int] = mapped_column(primary_key=True)
order_id: Mapped[int] = mapped_column(ForeignKey("order.id"))
product_id: Mapped[int] = mapped_column(ForeignKey("product.id"))
quantity: Mapped[int]
For Django, the same pattern: each models.Model is a table; ForeignKey / ManyToManyField are arrows. Tools like django-extensions can auto-generate the diagram with ./manage.py graph_models.
3. API Payloads — Request & Response Pair
For each API endpoint, draw the request and response schema side-by-side. The two boxes ARE the API contract; once you have them, generate OpenAPI from the Pydantic models so the spec doesn't drift.
┌──────────────────────────────────────────────────────────────────────────────────────────┐ │ Schema Diagram — API Payloads (Request & Response Pair) │ │ │ │ ┌──────────────────────────────────┐ ┌──────────────────────────────────┐ │ │ │ POST /orders │ │ 201 Created │ │ │ │ CreateOrderRequest │ │ OrderResponse │ │ │ ├──────────────────────────────────┤ ├──────────────────────────────────┤ │ │ │ customer_id: int │ │ order_id: int │ │ │ │ items: list[ItemSpec] │ │ status: OrderStatus │ │ │ │ currency: Literal["USD","EUR"] │ │ total: Decimal │ │ │ │ idempotency_key: UUID │ │ estimated_delivery: date │ │ │ └──────────────────────────────────┘ │ tracking_url: HttpUrl | None │ │ │ └──────────────────────────────────┘ │ │ │ │ Pair every endpoint with its request and response schema. The two boxes │ │ ARE the API contract. Generate OpenAPI from these — no separate spec to │ │ drift out of sync. │ └──────────────────────────────────────────────────────────────────────────────────────────┘
FastAPI implementation
from fastapi import FastAPI
from pydantic import BaseModel, HttpUrl
from typing import Literal
from uuid import UUID
from datetime import date
from decimal import Decimal
class ItemSpec(BaseModel):
product_id: int
quantity: int
class CreateOrderRequest(BaseModel):
customer_id: int
items: list[ItemSpec]
currency: Literal["USD", "EUR"]
idempotency_key: UUID
class OrderResponse(BaseModel):
order_id: int
status: Literal["pending", "confirmed", "shipped"]
total: Decimal
estimated_delivery: date
tracking_url: HttpUrl | None = None
app = FastAPI()
@app.post("/orders", response_model=OrderResponse, status_code=201)
def create_order(req: CreateOrderRequest) -> OrderResponse:
# ... business logic
return OrderResponse(...)
The OpenAPI spec, the Swagger UI at /docs, and the auto-generated client SDKs all come from these two Pydantic classes. The diagram is just the human-readable view of the same contract.
4. Notation Conventions
| Element | Convention |
|---|---|
| Stereotype (top of box) | <<dataclass>>, <<pydantic.BaseModel>>, <<TypedDict>>, <<Table>>, POST /endpoint |
| Class name | Centered below stereotype, no + / - visibility prefixes (Python doesn't enforce them) |
| Field row | name: type with PEP 484 syntax. Show defaults if non-trivial: currency: str = "USD" |
| Foreign key | customer_id: FK → Customer in the field row, with an arrow to the target box |
| Primary key | Suffix PK on the field row (id: int PK) |
| Relationship cardinality | Label arrows: 1 → 1, 1 → *, * → * |
| Omitted field detail | Replace long sections with ... + 8 more fields |
For HTML rendering, encode the angle brackets in stereotypes as HTML entities: <<dataclass>>. Otherwise the browser parses literal <<...>> as malformed tags and renders them empty.
5. When to Use Schema vs Class Diagram
| Situation | Use |
|---|---|
| Documenting a Pydantic / dataclass / NamedTuple model | Schema |
| Documenting an ORM model (Django / SQLAlchemy) | Schema (effectively an ER diagram) |
| Documenting an API endpoint contract | Schema (request + response pair) |
| Showing inheritance (3+ subclasses share a base) | Class diagram (with the inheritance arrow) |
| Documenting a Protocol / ABC + implementations | Class diagram (realization arrows) |
Extending a framework class (Django View, sklearn estimator) |
Class diagram (inheritance is meaningful) |
| Showing the structure of a service / orchestrator class | Probably neither — use a sequence or pipeline diagram instead |
Common Interview Questions:
Why drop the methods section for dataclasses?
Most dataclasses have no meaningful methods — they're auto-generated __init__, __repr__, __eq__. Showing those clutters the diagram. The few methods you do define on a dataclass (a format() helper, a property) belong in the source code, not the architecture overview. The schema is what consumers care about.
How do I show optional fields in the diagram?
Use Python 3.10+ syntax: shipping: AddressDTO | None = None. The | None immediately tells the reader the field is optional and the default is None. Pre-3.10 codebases use Optional[AddressDTO]; both render fine in a schema box.
What about Pydantic validators? Don't they belong on the diagram?
No — the validator is part of the type. customer_id: int = Field(gt=0) already says "positive integer." If a validator is genuinely complex (cross-field validation, business rule), put a one-line note below the box: "validator: total must equal sum of items". Don't add a methods section just for validators.
Should I put the database table name on a SQLAlchemy schema box?
Yes if it differs from the class name (__tablename__ = "customer_v2") — that's a meaningful detail. Otherwise the class name is fine. If you're working with multiple databases, prefix with the database/schema name: analytics.customer.
How do I diagram a TypedDict that inherits from another TypedDict?
Same as a dataclass with inheritance: stereotype <<TypedDict>>, draw the inheritance arrow up to the parent. Don't repeat parent fields in the child box — readers know they're inherited. If you need to show all fields, add a note: "inherits 4 fields from BaseEvent."
What's the difference between a schema diagram and an ER diagram?
An ER diagram is database-schema-specific: entities + relationships + cardinalities, no behavior. A schema diagram is the same idea but for any data structure — a Pydantic model is a "schema" but isn't a database entity. ER diagrams are a strict subset of schema diagrams; I use "schema" as the umbrella term so it covers ORM models AND Pydantic AND dataclasses uniformly.