Python

Verified against Claude Code · 2026-08-02

Design SQLAlchemy 2.0 ORM models that avoid N+1 queries by construction

A prompt for declarative SQLAlchemy 2.0 models using Mapped/mapped_column typing, with relationship loading strategies chosen deliberately per access pattern, instead of default lazy loading that quietly produces an N+1 query storm the first time a list view renders.

Claude CodeChatGPT (GPT-5.1)GitHub Copilot Chat4 fillable variables

The prompt

Ready to copy — highlighted parts are example details you can swap.

Design SQLAlchemy 2.0 ORM models for the domain below, using the modern declarative Mapped[...] typed style. The relationships need a deliberately chosen loading strategy per real access pattern — not the library default, which will produce an N+1 query problem the moment this is used in a list view.

DOMAIN
A blog: authors, posts, and comments on posts.

RELATIONSHIPS
An Author has many Posts; a Post has many Comments; a Comment belongs to one Post.

ACCESS PATTERNS
The main feed lists 20 posts at a time and always shows each post's comment count; an author's own dashboard shows their posts but comments are only loaded when a specific post is opened.

DATABASE
PostgreSQL 16

REQUIREMENTS
1. Use the SQLAlchemy 2.0 typed declarative style: class Model(Base) with Mapped[int] and mapped_column(...) annotations, not the legacy Column(...) class-attribute style — mixing the two styles in one codebase is a common source of confusing type-checker errors.
2. For every relationship in An Author has many Posts; a Post has many Comments; a Comment belongs to one Post., choose a loading strategy based on the actual access pattern in The main feed lists 20 posts at a time and always shows each post's comment count; an author's own dashboard shows their posts but comments are only loaded when a specific post is opened., not the SQLAlchemy default (lazy="select", which issues one extra query per parent row the first time the relationship is touched): selectinload for a one-to-many accessed in bulk (a single extra query for the whole batch, not one per row), joinedload for a many-to-one or one-to-one that's always needed alongside the parent, and lazy loading only where The main feed lists 20 posts at a time and always shows each post's comment count; an author's own dashboard shows their posts but comments are only loaded when a specific post is opened. confirms the relationship is genuinely rarely accessed and loading it eagerly would waste bandwidth on rows that never need it.
3. State explicitly, per relationship, which loading strategy you picked and why, referencing the specific access pattern that justifies it — a relationship configured without a stated reason is a relationship configured by accident.
4. Add appropriate indexes (index=True, or a composite Index(...) at the table level) on any column The main feed lists 20 posts at a time and always shows each post's comment count; an author's own dashboard shows their posts but comments are only loaded when a specific post is opened. shows being filtered or joined on frequently — a foreign key column that's never indexed turns a common filter into a full table scan as the table grows.
5. Use back_populates (not backref) for bidirectional relationships, and set cascade behavior explicitly (e.g. cascade="all, delete-orphan") rather than relying on the default, stating what happens to child rows when a parent is deleted.
6. If PostgreSQL 16 is Postgres-specific, use its native types where they fit (JSONB over a generic Text column for structured data, ARRAY where genuinely appropriate) rather than the lowest-common-denominator type that would also work on SQLite.
7. For any relationship configured with selectinload or joinedload here, confirm the query code that will actually use these models passes the right execution options (or that the relationship-level lazy= setting is sufficient on its own) — a relationship configured for eager loading still N+1s if the calling query re-specifies its own conflicting loader option, so state explicitly which side owns the final decision at query time.

OUTPUT FORMAT
1. The models, in dependency order.
2. A table: relationship, loading strategy chosen, and the access pattern that justifies it.
3. The indexes added, and what query each one is meant to speed up.
4. One sentence confirming whether the relationship-level default is sufficient on its own, or whether query-time loader options must also be set consistently.

Customize

Optional — swap in your own details for the highlighted parts above.

Why this works

Naming SQLAlchemy 2.0's Mapped[...]/mapped_column typed style specifically, rather than leaving the ORM style unconstrained, matters because a large share of existing SQLAlchemy code and tutorials still use the legacy Column(...) class-attribute pattern, and a prompt that doesn't pin the version-specific style reliably gets back a mix of both that either fails a strict mypy pass or, worse, works but abandons the type-checking benefit the 2.0 style exists to provide in the first place. Forcing a stated loading strategy per relationship, tied to a real access pattern rather than the library default, targets the single most common and most expensive SQLAlchemy production bug: lazy="select" issues one additional query per row the first time a relationship is accessed, which is invisible and fast in a unit test that touches one row, and becomes a literal N+1 query storm — twenty separate queries to render a feed of twenty posts — the moment the same code runs against a real list view, often not discovered until a slow-query log or a production incident makes it visible. The access_patterns field is what makes this a design decision instead of a guess, because the correct strategy genuinely differs by how the data is actually read: selectinload issues one extra query for the whole batch and is right for a one-to-many relationship accessed across many rows at once (the feed's comment count), while joinedload folds the relationship into the same query via a JOIN and is right for something always needed alongside its parent, and eagerly loading a relationship that's accessed only rarely (individual post comments, opened one at a time) wastes bandwidth fetching data most requests never use — there is no single default that's correct for both patterns in the same schema, which is exactly why SQLAlchemy leaves the choice to the caller rather than picking one for you.

What you get back

class Post(Base): __tablename__ = "posts" id: Mapped[int] = mapped_column(primary_key=True) author_id: Mapped[int] = mapped_column(ForeignKey("authors.id"), index=True) author: Mapped["Author"] = relationship(back_populates="posts") comments: Mapped[list["Comment"]] = relationship( back_populates="post", cascade="all, delete-orphan", lazy="selectinload" ) # selectinload: feed always needs a comment count across a batch of ~20 posts at once — # one extra query for the whole page, not 20 separate per-post queries. Table: Post.comments -> selectinload, justified by "main feed always shows comment count for a batch of posts"; Post.author -> joinedload, justified by "author name is always shown alongside every post, one-to-one". Index: author_id indexed to speed the dashboard's "posts by this author" filter, which runs on every dashboard load.

Verified against

Claude Code Sonnet 4.6 · 2026-08-02

Changelog

  • 2026-08-02 Initial publish, verified against Claude Code (Sonnet 4.6) on SQLAlchemy 2.0 with PostgreSQL 16.

Need this built into your business?

If a prompt isn't enough — custom software, built and maintained for you — that's Scult's day job.

EXPLORE CUSTOM SOFTWARE
All Python prompts

Check your AI visibility

One URL in, a 0–100 score and the exact fixes out.

RUN THE CHECK

Browse all the tools

15 tools across six categories
13 of them never send your data anywhere

Free · No signup · No trial clock

SEE THE DIRECTORY