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: &lt;&lt;dataclass&gt;&gt;. 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.


↑ Back to Top