Generate OpenAPI, GraphQL, SQLAlchemy, and TypeORM from Your Database Schema
Your PostgreSQL column says subscriber_count_cache. Your OpenAPI spec says subscriber_count. Your GraphQL type says subscriberCount. Your TypeORM entity says subscriberCountCache. Your SQLAlchemy model says subscriber_count.
Which one is right? All five. And that's the problem.
This is schema drift — the slow, silent divergence between your database, your API contract, and your ORM layer that starts the moment these representations are maintained separately. It doesn't happen because engineers are careless. It happens because there's no structural reason they should stay synchronized. Each representation lives in a different file, edited by a different person, on a different cadence. Eighteen months in, you have five artifacts that all claim to describe the same data model and agree on nothing.
The fix isn't more discipline. It isn't a code generator wired into your CI. It's a completely different workflow:
Design your database schema once in TalkingSchema. Then talk to your schema to generate every other representation — OpenAPI, GraphQL, SQLAlchemy, TypeORM — from the same canonical model, on demand.
That's the whole post. The rest of it just shows what that looks like.
The right way
Here's what database-first API design actually looks like in 2026:
- Model or connect your schema in TalkingSchema. Tables, relationships, enums, nullability, unique indexes — the diagram is rendered as a live ERD and stored as DBML under the hood (a portable format that survives any change you make later).
- Ask for what you need. "Generate an OpenAPI 3.1 spec for the articles CRUD endpoints, scoped under
/publications/{publication_id}/articles. Use the Base/Create/Full triad." Or — "Now give me the GraphQL SDL for the same domain with cursor-based pagination." Or — "Now SQLAlchemy 2.0 models for the content schema, with schema-qualified ForeignKey references." Each request is answered against the same canonical model.
Here's the exact thread used to generate every code example in this post. Open it, browse the entities, and try the prompts before reading further — every YAML, SDL, Python, and TypeScript block below was produced from the schema rendered on the right:
The rest of this post walks through what TalkingSchema produces for the four output formats most teams actually need — OpenAPI 3.1, GraphQL, SQLAlchemy 2.0, and TypeORM — and the database design decisions that quietly determine the shape of each one.
Why this matters: the real cost of schema drift
Before the formats themselves, a quick detour into why a schema-first, generate-everything workflow is worth changing your team's habits over.
Here's how drift typically starts. A backend engineer designs the PostgreSQL schema. A separate engineer writes the FastAPI routes and documents them in OpenAPI. A third engineer scaffolds the GraphQL layer. Nobody copies anyone else's work — they all write from their mental model of what the data looks like. The mental models agree at week one. By week twelve, after three refactors and two feature additions, they don't.
The symptoms are subtle at first: a nullable field in the database that's documented as required in the API spec. A renamed column (user_id → author_id) that propagated to the ORM but not to the GraphQL type. An enum value added to the database (status: 'scheduled') that the OpenAPI schema doesn't know about.
Then a client hits a 500 because they sent a request body that matched your documented API spec exactly — but your ORM rejected it because the spec was wrong.
Your database schema is your canonical data contract. Every other representation — OpenAPI components, GraphQL types, SQLAlchemy models, TypeORM entities — is a projection of that contract into a different format. The moment a projection is maintained independently, it becomes a liability.
Schema drift — when your database and your API types diverge — is not a deployment problem. It's a design problem. Fix the design layer (one source of truth, every other artifact generated from it), and the types stay in sync automatically.
This is why a conversational design surface like TalkingSchema matters more than another point-in-time code generator. A standalone generator solves one side of the drift (DBML → Prisma, say). TalkingSchema solves all of them at once, because every output is generated on demand from the same model, through the same chat, against a schema you've been iterating on continuously.
The running example: a content platform schema
The schema embedded above — and used for every example in the rest of this post — is a digital content platform, in the shape of Substack or Medium. Writers create publications, publish articles, and earn subscription revenue. Readers subscribe, react, and comment. An analytics schema tracks reading behavior.
It's a good teaching example because the entity model is genuinely relational (writers → publications → articles → comments → reactions), exercises enum handling and foreign-key cascades through a payment lifecycle, and spans three schema groups (content, monetization, analytics) — enough surface area to stress schema-qualified table support in every format below.
Database → OpenAPI: mapping tables, relations, and constraints to components
"Generate an OpenAPI 3.1 YAML spec for the articles CRUD endpoints, scoped under
/publications/{publication_id}/articles. Use the Base/Create/Full component triad. Treatstatusandvisibilityas top-level enum schema components referenced from both path parameters and response schemas."
Run it against the embedded thread above and watch the YAML stream in.
OpenAPI 3.1 introduced full JSON Schema alignment — meaning every components/schema object is a valid JSON Schema document. This matters for database-first generation because PostgreSQL's type system maps cleanly to JSON Schema primitives.
The mapping rules
| PostgreSQL type | OpenAPI schema |
|---|---|
uuid | type: string, format: uuid |
varchar(n) | type: string, maxLength: n |
text | type: string |
int, bigint | type: integer |
numeric(p,s) | type: number, format: double |
boolean | type: boolean |
timestamptz | type: string, format: date-time |
date | type: string, format: date |
jsonb | type: object (or {} for unconstrained) |
enum type | type: string, enum: [...] |
| NOT NULL | listed in required: [] |
| nullable | nullable: true (OAS 3.0) or type: [string, "null"] (OAS 3.1) |
The Base/Create/Full triad
One of the most common OpenAPI design mistakes is generating a single schema for each table and reusing it everywhere — as both a request body and a response type. This fails immediately because server-generated fields (id, created_at, updated_at) should be readOnly: true in responses and should be absent from create request bodies.
The clean pattern is a three-component triad for each resource:
components:
schemas:
ArticleBase:
# Mutable fields only — shared between Create and Update
type: object
properties:
title:
type: string
maxLength: 500
subtitle:
type: string
maxLength: 500
nullable: true
body_html:
type: string
cover_image_url:
type: string
nullable: true
status:
$ref: "#/components/schemas/ArticleStatus"
visibility:
$ref: "#/components/schemas/ArticleVisibility"
scheduled_at:
type: string
format: date-time
nullable: true
ArticleCreate:
# Request body for POST /articles
allOf:
- $ref: "#/components/schemas/ArticleBase"
required:
- title
- status
- visibility
Article:
# Full response type — server-generated fields layered onto Base
allOf:
- $ref: "#/components/schemas/ArticleBase"
- type: object
properties:
id:
type: string
format: uuid
readOnly: true
publication_id: { type: string, format: uuid }
author_id: { type: string, format: uuid }
created_at:
type: string
format: date-time
readOnly: true
# ... word_count, read_time_minutes, published_at, updated_at — all readOnly ...
required:
[
id,
publication_id,
author_id,
title,
status,
visibility,
created_at,
updated_at,
]
ArticleStatus:
type: string
enum: [draft, scheduled, published, archived]
description: Lifecycle state of an article
ArticleVisibility:
type: string
enum: [public, subscriber_only, paywall]
description: Access control level for readers
ArticleStatus and ArticleVisibility are top-level schema components — not inlined. Database enums should always become reusable $ref targets, because the same enum type often appears in multiple paths (filtering parameters, request bodies, and response schemas all reference ArticleStatus).
CRUD paths with pagination
A properly database-derived API path for articles looks like this:
paths:
/publications/{publication_id}/articles:
parameters:
- name: publication_id
in: path
required: true
schema:
type: string
format: uuid
get:
operationId: listArticles
summary: List articles for a publication
tags: [Articles]
parameters:
- name: status
in: query
schema:
$ref: "#/components/schemas/ArticleStatus"
- name: visibility
in: query
schema:
$ref: "#/components/schemas/ArticleVisibility"
- name: cursor
in: query
schema:
type: string
- name: limit
in: query
schema:
type: integer
default: 20
maximum: 100
responses:
"200":
description: Paginated list of articles
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: "#/components/schemas/Article"
next_cursor:
type: string
nullable: true
total:
type: integer
post:
operationId: createArticle
tags: [Articles]
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/ArticleCreate" }
responses:
"201":
content:
application/json:
schema: { $ref: "#/components/schemas/Article" }
Two database schema decisions directly shape this API:
- The
(publication_id, slug)unique index signals that articles are scoped to publications — hence the nested path/publications/{id}/articles, not/articles?publication_id=... - The
statusandvisibilitycolumns are enums, not unbounded strings — hence filter parameters that$refto the enum schema rather than accepting arbitrary strings
This is what "database-first API design" means in practice: your schema's constraints and index patterns inform the API contract, rather than being discovered after the fact.
Database → GraphQL: types, queries, mutations, and the N+1 trap
"Generate the GraphQL schema for reading publications with articles, authors, and comments. Use cursor-based pagination for articles and comments. Flag any relationships that could cause N+1 problems and explain how to handle them with DataLoader."
Same thread, same schema — just a different question.
GraphQL's type system and a relational database's schema share a common structure: named types with typed fields. The mapping is mostly mechanical — but one relational database pattern creates a notorious GraphQL performance problem. Understanding it before you write a single resolver is what separates a good schema from an expensive one.
The basic type mapping
Each database table becomes a GraphQL object type. Columns become fields. Enums become GraphQL enum types. Foreign keys become relationship fields rather than raw ID fields.
# Generated GraphQL SDL (excerpt)
enum ArticleStatus {
draft
scheduled
published
archived
}
enum ArticleVisibility {
public
subscriber_only
paywall
}
type Article {
id: ID!
publication: Publication!
author: Writer!
title: String!
subtitle: String
bodyHtml: String
coverImageUrl: String
status: ArticleStatus!
visibility: ArticleVisibility!
wordCount: Int!
readTimeMinutes: Int!
publishedAt: String
createdAt: String!
updatedAt: String!
# Relationships
tags: [Tag!]!
comments(first: Int, after: String): CommentConnection!
reactions: [Reaction!]!
}
type Writer {
id: ID!
username: String!
displayName: String!
status: WriterStatus!
subscriberCountCache: Int!
# ... bio, avatarUrl, joinedAt ...
publications: [Publication!]!
articles(first: Int, after: String): ArticleConnection!
}
type Publication {
id: ID!
writer: Writer!
name: String!
slug: String!
# ... planType, subscriptionPriceCents, memberships ...
articles(
status: ArticleStatus
visibility: ArticleVisibility
first: Int
after: String
): ArticleConnection!
}
Two things worth noticing on the Article type:
- Foreign keys become object relationships, not raw IDs. Exposing
publicationId: ID!pushes the join responsibility to the client; the FK column should be a resolver target, not a first-class field. - Unbounded relations use Connection types, not bare arrays. A popular article can have thousands of comments;
[Comment!]!breaks at scale. Cursor pagination has to be designed into the schema before any resolver is written.
The N+1 trap and how schema design prevents it
Here's the query that causes N+1:
query {
publication(id: "abc") {
articles(first: 20) {
title
author {
displayName
avatarUrl
}
}
}
}
Without optimization, this resolves as: 1 query to fetch 20 articles + 20 queries to fetch each article's author = 21 database queries for a trivially simple page. At 100 articles, it's 101 queries.
The immediate solution is DataLoader — Facebook's batching and caching utility that groups multiple .load(authorId) calls within a single resolver tick into one SELECT * FROM content.writers WHERE id IN (uuid1, uuid2, ..., uuid20). This is correct and necessary for any relationship resolver that isn't a direct JOIN.
The schema design solution is recognizing which relationships are safe to resolve eagerly vs. lazily. The author field on Article is a many-to-one relationship (many articles share one author). It has bounded cardinality from the parent's perspective and is almost always needed alongside the article. This is a candidate for JOIN-based resolution — a single SELECT articles JOIN writers query — rather than a DataLoader batch.
Contrast this with comments on Article. This is a one-to-many relationship with potentially unbounded cardinality. It should always be resolved lazily, with DataLoader batching or a separate paginated query. Exposing it as [Comment!]! (bare array) is a schema design mistake: it invites the client to fetch it unconditionally, with no way to paginate.
The schema design implication:
type Article {
# Many-to-one: safe for JOIN resolution, almost always needed
author: Writer!
publication: Publication!
# One-to-many, unbounded: must paginate; don't make it eagerly loadable
comments(first: Int, after: String): CommentConnection!
reactions: [Reaction!]!
# Many-to-many junction: usually small per article; acceptable as array
tags: [Tag!]!
}
The DataLoader pattern (batching N resolver calls into one WHERE id IN (...)
query) solves the N+1 problem for many-to-one relationships. For one-to-many
relationships with unbounded cardinality — like comments on Article — no
amount of batching fixes a bare array field. Pagination is the only correct
answer, and it has to be designed into the schema before any resolver is
written.
The rule of thumb: if cardinality from parent to child is bounded and small (tags per article, typically 0–5), bare arrays are fine. Unbounded (comments per article) means always paginate. Many-to-one (article to author) means consider JOIN resolution over DataLoader to reduce round trips.
Mutations from the database schema
Database constraints directly determine mutation design. The (article_id, user_id, reaction_type) unique constraint on content.reactions tells you:
createReactionshould return an error (or upsert) if the same user reacts with the same type to the same article- The mutation input should include all three uniqueness fields
- There's no
updateReaction— you can only create or delete
input CreateReactionInput {
articleId: ID!
reactionType: ReactionType!
# userId comes from authentication context — not in input
}
type Mutation {
createReaction(input: CreateReactionInput!): Reaction!
deleteReaction(articleId: ID!, reactionType: ReactionType!): Boolean!
# Note: no updateReaction — the unique constraint makes this nonsensical
}
This is database constraints driving mutation design. The unique index on (reader_id, publication_id) in monetization.subscriptions tells you the same thing: a createSubscription mutation that doesn't check for an existing active subscription will silently break the uniqueness invariant.
Database → SQLAlchemy 2.0: the Python ORM view
"Generate SQLAlchemy 2.0 models for the content schema — Writer, Publication, and Article. Use
Mappedtype annotations, schema-qualified ForeignKey references (content.writers.id), and__table_args__withschema='content'. Include all enum types with the correct PostgreSQL schema qualifier."
SQLAlchemy 2.0's Mapped typing API is a significant departure from the 1.x Column() style. If your codebase is still on 1.x, the generated models below will look unfamiliar — which is the point. The 2.0 style is what new Python projects should target.
The key changes in SQLAlchemy 2.0
| SQLAlchemy 1.x | SQLAlchemy 2.0 |
|---|---|
Column(String(200)) | mapped_column(String(200)) |
column: str (no annotation) | column: Mapped[str] |
declarative_base() | class Base(DeclarativeBase) |
Nullability from nullable=False | Nullability inferred from Mapped[str] vs Mapped[Optional[str]] |
| Relationships untyped | Mapped[List["Article"]] typed relationship |
The Mapped annotation makes the type system the source of truth for nullability — Mapped[str] is non-null, Mapped[Optional[str]] is nullable. The mapped_column() doesn't need to repeat nullable=False; it infers it from the annotation. This reduces redundancy and catches mistakes at static analysis time rather than at runtime.
Generated models for the content schema
# models/content.py
from __future__ import annotations
import enum
import uuid
from datetime import datetime
from typing import Optional, List
from sqlalchemy import (
BigInteger, Boolean, CheckConstraint, DateTime, Enum,
ForeignKey, Integer, String, Text, UniqueConstraint, func
)
from sqlalchemy.dialects.postgresql import JSONB, UUID as PG_UUID
from sqlalchemy.orm import (
DeclarativeBase, Mapped, mapped_column, relationship
)
class Base(DeclarativeBase):
pass
class WriterStatus(enum.Enum):
active = "active"
suspended = "suspended"
deleted = "deleted"
class ArticleStatus(enum.Enum):
draft = "draft"
scheduled = "scheduled"
published = "published"
archived = "archived"
class ArticleVisibility(enum.Enum):
public = "public"
subscriber_only = "subscriber_only"
paywall = "paywall"
class Writer(Base):
__tablename__ = "writers"
__table_args__ = (
UniqueConstraint("username"),
UniqueConstraint("email"),
{"schema": "content"},
)
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid())
username: Mapped[str] = mapped_column(String(60), nullable=False)
status: Mapped[WriterStatus] = mapped_column(Enum(WriterStatus, schema="content"), nullable=False, server_default="active")
# ... display_name, slug, bio, avatar_url, email, joined_at, subscriber_count_cache, verified_at ...
publications: Mapped[List["Publication"]] = relationship("Publication", back_populates="writer")
articles: Mapped[List["Article"]] = relationship("Article", back_populates="author", foreign_keys="[Article.author_id]")
class Publication(Base):
__tablename__ = "publications"
__table_args__ = ({"schema": "content"},)
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid())
writer_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("content.writers.id"), nullable=False)
# ... name, slug, description, language, subscription_price_cents, trial_days, created_at ...
writer: Mapped["Writer"] = relationship("Writer", back_populates="publications")
articles: Mapped[List["Article"]] = relationship("Article", back_populates="publication")
class Article(Base):
__tablename__ = "articles"
__table_args__ = (
UniqueConstraint("publication_id", "slug"),
{"schema": "content"},
)
id: Mapped[uuid.UUID] = mapped_column(PG_UUID(as_uuid=True), primary_key=True, server_default=func.gen_random_uuid())
publication_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("content.publications.id"), nullable=False)
author_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("content.writers.id"), nullable=False)
title: Mapped[str] = mapped_column(String(500), nullable=False)
slug: Mapped[str] = mapped_column(String(300), nullable=False)
subtitle: Mapped[Optional[str]] = mapped_column(String(500))
body_html: Mapped[Optional[str]] = mapped_column(Text)
body_tiptap_json: Mapped[Optional[dict]] = mapped_column(JSONB)
cover_image_url: Mapped[Optional[str]] = mapped_column(Text)
status: Mapped[ArticleStatus] = mapped_column(Enum(ArticleStatus, schema="content"), nullable=False, server_default="draft")
visibility: Mapped[ArticleVisibility] = mapped_column(Enum(ArticleVisibility, schema="content"), nullable=False, server_default="public")
word_count: Mapped[int] = mapped_column(Integer, server_default="0")
read_time_minutes: Mapped[int] = mapped_column(Integer, server_default="1")
published_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
scheduled_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
publication: Mapped["Publication"] = relationship("Publication", back_populates="articles")
author: Mapped["Writer"] = relationship("Writer", back_populates="articles", foreign_keys=[author_id])
tags: Mapped[List["Tag"]] = relationship("Tag", secondary="content.article_tags", back_populates="articles")
comments: Mapped[List["Comment"]] = relationship("Comment", back_populates="article", cascade="all, delete-orphan")
reactions: Mapped[List["Reaction"]] = relationship("Reaction", back_populates="article", cascade="all, delete-orphan")
__table_args__ = (..., {"schema": "content"}) is how SQLAlchemy 2.0 handles schema-qualified tables. The ForeignKey references must also be schema-qualified: ForeignKey("content.writers.id"). Getting this wrong produces silent failures — SQLAlchemy will resolve the FK against the public schema and the constraint will either fail migration or silently not enforce at the ORM level.
The Enum(ArticleStatus, schema="content") argument tells SQLAlchemy that the enum type lives in the content schema in PostgreSQL, not the default public schema. This matters when running alembic upgrade head — without the schema argument, Alembic will try to create the enum type in the wrong schema.
Database → TypeORM: NestJS entity generation
"Generate TypeORM entity classes for the content schema — Article, Writer, Publication, and Comment. Use
schema: 'content'on all@Entitydecorators, schema-qualified@JoinTablefor article_tags, and TypeScript enums for status and visibility columns. Include the NestJSContentModuleregistration."
TypeORM's decorator model maps almost identically to the database schema — each @Entity class corresponds to a table, each @Column to a column, each @OneToMany/@ManyToOne to a foreign key relationship. The primary advantage over SQLAlchemy for TypeScript teams is that the entity is also a TypeScript type: you get runtime column metadata and compile-time type safety from the same class definition.
Entity structure for the content schema
// src/content/entities/article.entity.ts
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
OneToMany,
ManyToMany,
JoinTable,
JoinColumn,
CreateDateColumn,
UpdateDateColumn,
Index,
Unique,
} from "typeorm";
import { Publication } from "./publication.entity";
import { Writer } from "./writer.entity";
import { Tag } from "./tag.entity";
import { Comment } from "./comment.entity";
import { Reaction } from "./reaction.entity";
export enum ArticleStatus {
DRAFT = "draft",
SCHEDULED = "scheduled",
PUBLISHED = "published",
ARCHIVED = "archived",
}
export enum ArticleVisibility {
PUBLIC = "public",
SUBSCRIBER_ONLY = "subscriber_only",
PAYWALL = "paywall",
}
@Entity("articles", { schema: "content" })
@Unique(["publication", "slug"])
@Index(["status", "visibility"])
export class Article {
@PrimaryGeneratedColumn("uuid")
id: string;
@Column({ type: "varchar", length: 500 })
title: string;
@Column({ type: "enum", enum: ArticleStatus, default: ArticleStatus.DRAFT })
status: ArticleStatus;
@Column({
type: "enum",
enum: ArticleVisibility,
default: ArticleVisibility.PUBLIC,
})
visibility: ArticleVisibility;
// ... slug, subtitle, bodyHtml, bodyTiptapJson, coverImageUrl, wordCount,
// readTimeMinutes, publishedAt, scheduledAt, createdAt, updatedAt ...
// ── Relationships ──────────────────────────────────────────────────────
@ManyToOne(() => Publication, (pub) => pub.articles, { nullable: false })
@JoinColumn({ name: "publication_id" })
publication: Publication;
@ManyToOne(() => Writer, (writer) => writer.articles, { nullable: false })
@JoinColumn({ name: "author_id" })
author: Writer;
@ManyToMany(() => Tag, (tag) => tag.articles)
@JoinTable({
name: "article_tags",
schema: "content",
joinColumn: { name: "article_id", referencedColumnName: "id" },
inverseJoinColumn: { name: "tag_id", referencedColumnName: "id" },
})
tags: Tag[];
@OneToMany(() => Comment, (comment) => comment.article, { cascade: true })
comments: Comment[];
@OneToMany(() => Reaction, (reaction) => reaction.article, { cascade: true })
reactions: Reaction[];
}
The TypeORM equivalent of the SQLAlchemy gotcha: { schema: "content" } must appear on both @Entity and @JoinTable. The @Entity qualifier is the one most developers remember; the @JoinTable qualifier is the one most multi-schema projects forget, which silently places junction tables in public and produces the drift you spent the rest of this section trying to avoid.
The module-per-schema-group pattern for NestJS
The three schema groups (content, monetization, analytics) map naturally to NestJS feature modules. This isn't just organizational — it reflects the domain boundaries in the data model:
// src/content/content.module.ts
@Module({
imports: [
TypeOrmModule.forFeature([
Writer,
Publication,
PubMembership,
Article,
Tag,
ArticleTag,
Comment,
Reaction,
]),
],
providers: [ArticleService, WriterService, PublicationService],
exports: [ArticleService, WriterService],
})
export class ContentModule {}
// src/monetization/monetization.module.ts
@Module({
imports: [
TypeOrmModule.forFeature([Reader, Subscription, Payment, Payout, Referral]),
],
providers: [SubscriptionService, PaymentService],
exports: [SubscriptionService],
})
export class MonetizationModule {}
The schema group boundaries in your DBML become the module boundaries in your NestJS application. This is the database schema driving architectural decisions — not the other way around.
Auto-generated APIs: PostgREST, Hasura, Supabase — when to use them
The tools above assume you're writing a custom API. Three platforms will generate an API from your database schema automatically, with zero code:
PostgREST 12 introspects your PostgreSQL schema and serves a REST API over every table and view. Every table gets GET, POST, PATCH, and DELETE endpoints. Row-level security (RLS) policies become access control rules. The API documentation is automatically served as OpenAPI from the PostgreSQL table comments.
Hasura v3 introspects your schema and generates a GraphQL API — queries, mutations, subscriptions, and relationships, all derived from foreign keys. It adds a metadata layer for permissions, computed fields, and remote joins.
Supabase uses PostgREST under the hood and generates a TypeScript client (supabase-js) that's typed against your schema. The types come from supabase gen types typescript — effectively a type-safe PostgREST client derived from your actual database structure.
PostgREST, Hasura, and Supabase auto-generate APIs from your database. They're excellent for standard CRUD. They break down when your API needs to enforce business logic that can't live in the database.
When auto-generation solves the problem
Auto-generated APIs are excellent when:
- You want CRUD semantics without custom business logic — PostgREST and Hasura can handle most read/write operations for a content platform's
articlestable without a single line of server code - Your access control is expressible as row-level security policies — Supabase RLS policies on
content.articlescan enforce visibility rules (public vs. subscriber_only vs. paywall) at the database level - You're moving fast and the database schema is not yet stable — auto-generated APIs track schema changes automatically; the API is always in sync with the database because it's derived from it at request time
When auto-generation breaks down
| Scenario | Problem with auto-generation |
|---|---|
| Custom business logic in mutations | PostgREST/Hasura expose tables directly; custom validation requires PostgreSQL functions or a sidecar service |
Non-CRUD semantics (/publish, /archive) | Auto-generated APIs are table-centric; state machine transitions don't map cleanly to CRUD |
| Multi-step transactions | A POST /subscriptions that also creates a Stripe subscription and records an email event requires server-side coordination |
| Cross-database aggregations | Analytics queries across content, monetization, and analytics schema groups often require custom SQL |
| API versioning | Auto-generated APIs break backward compatibility when columns are renamed or removed |
| Response shaping | Excluding sensitive fields (like email_hash) from responses requires RLS policies or views — adding complexity |
The honest take: PostgREST/Hasura/Supabase are the right choice for internal tooling, rapid prototyping, and simple CRUD surfaces. For a production content platform — with subscription gating, payment processing, and delivery-guaranteed email events — you'll reach the boundaries of auto-generation within weeks.
The pattern that works at scale: use auto-generation for read paths (article listing, subscription status lookups, analytics dashboards) and custom API handlers for write paths that involve multi-step transactions or external service calls. Your TalkingSchema ERD remains the source of truth for both.
Designing your schema with the API in mind
The sections above show how schema design propagates outward. A few decisions made at the database level make every downstream output cleaner:
- Use
snake_casecolumns in PostgreSQL and accept camelCase transformation at each API layer. Trying to use camelCase in the database to "match" the API is the inverse mistake — the transformation is cheap; the inconsistency is expensive. - Default to
NOT NULLwhen you're unsure. A NOT NULL constraint becomesrequiredin OpenAPI,String!in GraphQL,Mapped[str]in SQLAlchemy, andnullable: falsein TypeORM. Relaxing it later is safe (schema widening); tightening it after data exists requires a backfill. - Use native enum types, not VARCHAR + CHECK. A PostgreSQL enum propagates cleanly to every layer (
enum: [...]in OpenAPI,enum ArticleStatus { ... }in GraphQL,Enum(...)in SQLAlchemy,enum:in TypeORM). A CHECK constraint forces every generator to infer valid values from the expression — harder, less reliable, and the inferred set drifts the moment someone edits the constraint.
Where this is going: an MCP that holds the schema and vets every projection
The workflow this post describes fixes the generation side of drift. Open the canvas, ask for OpenAPI, ask for GraphQL, ask for SQLAlchemy — every output is rederived from the same canonical model on demand. Re-ask after a schema change, and the projections stay in step.
What it doesn't yet fix is the gap between the canonical schema and the artifacts that already exist in your codebase. Your openapi.yaml was hand-edited last sprint. Your SQLAlchemy Article class was extended with a custom @hybrid_property. Your GraphQL SDL was reshaped to expose a different pagination contract. These files live in your repo, drift independently, and never call home to TalkingSchema to compare notes.
This is a real ecosystem gap. Today, every drift detection tool monitors one layer in isolation:
| Tool | What it monitors |
|---|---|
| Atlas | Live database vs. desired DB state |
| Prisma Schema Engine | Prisma schema vs. database |
| GraphQL Inspector | GraphQL schema vs. previous GraphQL schema |
| CodeRifts / DriftGuard | OpenAPI spec vs. previous OpenAPI spec |
mcp-contracts (mcpdiff) | MCP tool schemas vs. previous snapshots |
Each tool checks a layer against its own history. None of them check across layers. Add a scheduled value to your article_status enum in the database; Atlas reports clean drift; GraphQL Inspector reports a clean diff; your OpenAPI linter reports nothing — and yet the OpenAPI enum, the GraphQL enum ArticleStatus, and the SQLAlchemy enum class are all silently wrong until a runtime 500.
The three things an MCP for TalkingSchema would do
- Read. Expose the canonical DBML and every generated export (OpenAPI, GraphQL, SQLAlchemy, TypeORM, Drizzle, Prisma, Zod) to Claude Code, Cursor, or any MCP-compatible agent. The coding agent always has the latest model in context — no copy-paste, no stale snapshot.
- Write. Coding agents can propose schema edits back through the MCP. The proposal lands as a patch on the TalkingSchema canvas, ready to review. The schema designer agent and the coding agent are both collaborating on the same artifact, which is hosted on TalkingSchema.
- Vet. The differentiated one. Given the canonical schema and one or more downstream artifacts in your repo (
openapi.yaml,schema.graphql,models.py,entities.ts), return a structured drift report — enum values missing, columns renamed, nullability flipped, foreign keys removed, response shapes diverging — that the agent can then act on. Patch the OpenAPI spec. Regenerate the SQLAlchemy class.
The first two are plumbing — useful for parity with the half-dozen schema MCPs that shipped in 2025–2026 (Prisma's, Supabase's, DrawDB's, DBHub). The third is the actual moat. It's also the reason nobody has built it yet: cross-layer drift detection is genuinely hard, because each artifact has its own grammar, its own conventions, and its own ways of encoding the same constraint. Doing it well requires parsing each format back to a canonical representation and reasoning about structural equivalence — not just text diffs.
Follow the release notes if you want to track the rollout.
Bringing it back: the schema is the spec
Your database schema is the specification. Every other representation is a derivation.
That sentence is one paragraph long, but the operational shift it implies is substantial. Maintaining four separate artifacts by hand — OpenAPI, GraphQL, SQLAlchemy, TypeORM — guarantees drift. Not because engineers are careless, but because the system has no structural reason to stay synchronized. Schema drift is a workflow problem, not a discipline problem, and you cannot fix it with code review.