frontier-python-ts/skills/postgres-schema-design/SKILL.md
Use when designing or modifying a PostgreSQL database schema, adding tables or columns, creating indexes, writing migrations, or making any structural database change. The data layer in this harness is SQLAlchemy 2.0 async + Alembic. Use alongside the `sqlalchemy` skill (which covers querying, eager loading, transactions).
npx skillsauth add jon23d/skillz postgres-schema-designInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
All schema changes go through Alembic migrations. Never apply raw DDL directly to the database.
The migration files are the source of truth for schema state.
Look for alembic.ini and alembic/env.py at the backend service root. If they do not exist, run the bootstrap section below before changing any model.
uv add alembic sqlalchemy[asyncio] asyncpg
uv run alembic init -t async alembic
The -t async template generates an env.py that uses an async engine. Edit it to import your Base and read the URL from settings:
# alembic/env.py
import asyncio
from logging.config import fileConfig
from sqlalchemy.ext.asyncio import async_engine_from_config
from sqlalchemy import pool
from alembic import context
from app.core.config import get_settings
from app.models.base import Base
import app.models # noqa: F401 — ensure all models are imported
config = context.config
config.set_main_option("sqlalchemy.url", str(get_settings().database_url))
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection):
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online():
connectable = async_engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())
compare_type=True and compare_server_default=True make autogenerate detect column-type and server-default changes, which Alembic does not catch by default.
The import app.models line is critical: autogenerate only sees what Base.metadata knows about, and Base only knows about modules that have been imported.
app/models/<resource>.py. Add the field, the relationship, the index, or the constraint.uv run alembic revision --autogenerate -m "add stripe_customer_id to users"
alembic/versions/. Autogenerate is good but not perfect — verify:
op.drop_column or op.drop_table statements (autogenerate cannot tell a rename from a delete + add)nullable= matches your intentondelete= clauses on foreign keys are correctuv run alembic upgrade head
Autogenerate cannot detect renames — it produces a drop + add, which destroys data. For renames, hand-edit the migration:
def upgrade():
op.alter_column("users", "name", new_column_name="full_name")
def downgrade():
op.alter_column("users", "full_name", new_column_name="name")
If a column is NOT NULL but new, you cannot just add it — existing rows have no value. Split into three migrations:
op.execute("UPDATE users SET tier = 'free' WHERE tier IS NULL").NOT NULL.In practice, do this in one migration file with three sequential operations — but read it as three logical steps.
uv run alembic upgrade head
That is the only command CI needs. Do not run autogenerate in CI.
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
)
String UUIDs are the default. They serialise cleanly to JSON and survive every layer without conversion. Use the Postgres UUID type only if you need DB-side gen_random_uuid().
Use the TimestampMixin from the sqlalchemy skill:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
deleted_at: Mapped[datetime | None] = mapped_column( # only if soft delete
DateTime(timezone=True),
nullable=True,
)
Always timezone=True. Never DateTime without it. Postgres stores everything as UTC; timezone=True makes Python aware of that fact and prevents naïve-vs-aware bugs.
organization_id: Mapped[str] = mapped_column(
String(36),
ForeignKey("organizations.id", ondelete="CASCADE"),
nullable=False,
)
organization: Mapped["Organization"] = relationship(back_populates="users")
Every FK has an ondelete clause. The default is NO ACTION, which means orphaned rows. Pick one of:
CASCADE — child rows go when the parent does (e.g. organization → its users)SET NULL — child rows survive but lose the link (rare)RESTRICT — block parent deletion if children exist (e.g. invoice → orders)__table_args__ = (
Index("ix_users_organization_id", "organization_id"),
)
Postgres does not create indexes for FK columns automatically. Without one, every join and every cascade delete becomes a sequential scan.
__table_args__ = (
Index("ix_users_org_email", "organization_id", "email", unique=True),
Index("ix_users_org_created", "organization_id", "created_at"),
)
Index for the query you actually run. tenant_id alone is rarely enough — see the multi-tenancy skill.
__table_args__ = (
UniqueConstraint("organization_id", "email", name="uq_users_org_email"),
)
Always name your constraints. Postgres-generated names are unstable across migrations.
Use Python Enum classes mapped to the Postgres ENUM type:
import enum
from sqlalchemy import Enum
class SubscriptionStatus(str, enum.Enum):
TRIALING = "trialing"
ACTIVE = "active"
PAST_DUE = "past_due"
CANCELED = "canceled"
# In the model:
status: Mapped[SubscriptionStatus] = mapped_column(
Enum(SubscriptionStatus, name="subscription_status"),
nullable=False,
default=SubscriptionStatus.TRIALING,
)
Two important things:
str, enum.Enum — the dual base class makes the values JSON-serialisable as plain strings.name="subscription_status" — the Postgres ENUM type needs an explicit name; without one Alembic generates a random one.Adding a new enum value requires op.execute("ALTER TYPE subscription_status ADD VALUE 'paused'") in a migration — autogenerate will miss it.
class UserRole(Base):
__tablename__ = "user_roles"
user_id: Mapped[str] = mapped_column(
String(36), ForeignKey("users.id", ondelete="CASCADE"), primary_key=True
)
role_id: Mapped[str] = mapped_column(
String(36), ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True
)
granted_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
Always make the join table explicit. The day you need to add granted_by, expires_at, or any other column, you will be glad it is a real model.
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
Or use Numeric(precision=12, scale=2) if you need decimals — never Float.
String(200), String(320) (email max), String(36) (UUID). Bare String (= unbounded TEXT) is fine for prose-like content but explicit limits force you to think about size.
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
A nullable boolean has three states (true, false, NULL). That is almost never what you want.
NOT NULLEvery column starts nullable=False. Only mark a column nullable when there is a real reason — e.g. an optional field that can legitimately be unset, or a soft-delete deleted_at.
session.execute(text("ALTER TABLE ...")) — never. Always Alembic.Base.metadata.create_all() in production code paths — Alembic owns the schema.-m messages are part of the audit trail.ondelete=NO ACTION by default — creates orphaned rows on delete.String for things with a known max length — kicks the can on data hygiene.Float for money — incorrect arithmetic, eventually a billing incident.For querying, eager loading, transactions, soft deletes, and pagination patterns, see the sqlalchemy skill. This skill covers schema; that skill covers access.
development
Use when adding or modifying environment variable handling in TypeScript projects or monorepos — especially when using process.env directly, missing startup validation, sharing env schemas across packages, or encountering "undefined is not a string" errors at runtime from missing env vars.
testing
Use when creating a new skill, editing an existing skill, writing a SKILL.md, or verifying a skill works before deployment.
development
React UI design principles and conventions. Load when building or modifying any user interface or React components. Covers application type detection, visual standards, component design and structure, Mantine (business apps) and Tailwind (consumer apps), accessibility, responsiveness, state management, data fetching, testing, and in-app help patterns.
development
Use when setting up ESLint and/or Prettier in a TypeScript project, adding linting to an existing TypeScript codebase, or configuring typescript-eslint, eslint-config-prettier, or related packages.