Fastcrud logo

Fastcrud

OrganizationPopular
benavlabs
fastcrud

Use when building or modifying CRUD endpoints with FastCRUD (the `fastcrud` PyPI package) in a FastAPI project — covers `FastCRUD`, `crud_router`, `EndpointCreator`, `FilterConfig`, `JoinConfig`, auto-relationship detection, the filter operator syntax (`__gte`, `__in`, `__ilike`, etc.), cursor pagination, soft delete, and how to avoid N+1 queries when fetching related data. Activate when the user mentions FastCRUD, `crud_router`, `FastCRUD()`, `FilterConfig`, `JoinConfig`, or asks how to build CRUD endpoints / generate REST APIs from SQLAlchemy or SQLModel models, even if the user doesn't name the library directly.

Overview

Publisherbenavlabs
Repositoryfastcrud
Skill namefastcrud
Stars
1.6K
Forks
115
Bundled files
6
LicenseMIT
Links
  • Markdown instructions

    A SKILL.md file the model loads on demand, so it only costs tokens when a request actually matches.

  • Works with any LLM

    AI skills are plain Markdown, not provider-specific code, so this works with GPT, Claude, Gemini, Grok, or a local model.

  • 6 bundled files

    Scripts, templates, and references the model can read while it works. Files are read-only and never executed.

  • Open source

    Published by benavlabs on GitHub. Read the source before you install it.

Installation

Install the Fastcrud AI skill in TypingMind to use it with any LLM, or drop it into another agent that reads SKILL.md.

1

Install in TypingMind

TypingMind installs a skill straight from its GitHub folder — it reads SKILL.md, bundles the resource files, and stores the result locally.

  1. Open the app and go to Plugins → Skills.
  2. Choose "Install from GitHub".
  3. Paste the skill folder URL below and confirm.
    https://github.com/benavlabs/fastcrud/tree/main/fastcrud/.agents/skills/fastcrud
  4. Enable the skill in any chat where you want it available.
Plugins → Skills → Add skill → From GitHub URL, then paste the folder URL and press Continue.
2

Install in another agent

Any agent that reads the Agent Skills format can use this skill — copy the folder into that agent's skills directory.

Claude Code — .claude/skills
git clone --depth 1 https://github.com/benavlabs/fastcrud.git /tmp/fastcrud
mkdir -p .claude/skills
cp -r /tmp/fastcrud/fastcrud/.agents/skills/fastcrud .claude/skills/fastcrud
Restart Claude Code after copying so it picks up the new skill.

Use it in TypingMind

Enable Fastcrud in any TypingMind chat and the model takes it from there. Its name and description sit in the system prompt, and the moment a request matches, the model loads the full instructions itself — you never invoke it by hand, and it costs no tokens until it is actually used.

The model loads Fastcrud on its own as soon as a request matches it.

Works with any AI model

AI skills are plain Markdown instructions rather than provider-specific code, so Fastcrud is not tied to the model it was written for. Install it once in TypingMind and use it with GPT-5, Claude, Gemini, Grok, DeepSeek, Mistral, Llama, or a local model you run yourself — all on your own API keys.

  • Loaded only when it is needed

    The system prompt carries just the name and description. The instructions are fetched on the first matching request, so an idle skill costs nothing.

  • Switch models mid-chat

    Because the skill is instructions rather than code, changing model does not break it — the next model reads the same SKILL.md.

Skill instructions

This is the SKILL.md content the model loads. Read it before installing — a skill is instructions your model will follow.

FastCRUD

FastCRUD generates async CRUD methods (and optionally CRUD endpoints) for SQLAlchemy 2.0 models inside a FastAPI app. The two entry points are:

  • FastCRUD(Model) — the data-access class. Use this for per-row CRUD operations (single-record get / create / update / delete, paginated lists, joins with relationships). Real codebases use this in services, workers, and custom endpoints.
  • crud_router(...) — returns an APIRouter with create/read/update/delete endpoints auto-wired. Use this only when you want generated endpoints; many apps skip it and hand-roll FastAPI routes on top of FastCRUD instances.

This skill covers the public API across fastcrud >= 0.22; features marked (v0.23+) need fastcrud >= 0.23. SQLAlchemy is the default ORM in examples; SQLModel works the same way except where noted.

Before reaching for FastCRUD, check the When NOT to use FastCRUD section below. Aggregate roll-ups, CTEs, GROUP BY projections, and bulk writes are SQLAlchemy's job — FastCRUD is for per-row CRUD.


Canonical setup

The minimal viable pattern. Always start here, then add filters/joins/relationships as needed.

python
# models.py
from sqlalchemy import Column, Integer, String, ForeignKey
from sqlalchemy.orm import DeclarativeBase, relationship

class Base(DeclarativeBase): pass

class Tier(Base):
    __tablename__ = "tier"
    id = Column(Integer, primary_key=True)
    name = Column(String, unique=True)

class User(Base):
    __tablename__ = "user"
    id = Column(Integer, primary_key=True)
    name = Column(String)
    email = Column(String, unique=True)
    tier_id = Column(Integer, ForeignKey("tier.id"))
    tier = relationship("Tier")
python
# schemas.py
from pydantic import BaseModel, ConfigDict

class UserCreate(BaseModel):
    name: str
    email: str
    tier_id: int

class UserRead(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: int
    name: str
    email: str
    tier_id: int

class UserUpdate(BaseModel):
    name: str | None = None
    email: str | None = None
python
# main.py
from fastapi import FastAPI
from fastcrud import crud_router
from .db import get_async_session
from .models import User
from .schemas import UserCreate, UserRead, UserUpdate

app = FastAPI()

user_router = crud_router(
    session=get_async_session,    # callable returning AsyncSession (FastAPI dependency)
    model=User,
    create_schema=UserCreate,
    update_schema=UserUpdate,
    select_schema=UserRead,        # used for read responses
    path="/users",
    tags=["users"],
)
app.include_router(user_router)

This produces POST /users, GET /users/{id}, PATCH /users/{id}, DELETE /users/{id}, DELETE /users/db_delete/{id} (hard delete), and GET /users (paginated list).


Choosing the right method

When using FastCRUD(Model) directly, pick the method that matches the data shape you actually need. Do not loop over get_multi() and call get_joined() per row — that's the canonical N+1.

NeedUse
Single row, no joinsget(db, id=...)
Single row with related dataget_joined(db, id=...)
Paginated list, no joinsget_multi(db, offset, limit)
Paginated list with related dataget_multi_joined(db, ...)
Cursor-paginated list (infinite scroll)get_multi_by_cursor(db, ...)
Insertcreate(db, schema)
Insert-or-update by unique constraintupsert(db, schema) / upsert_multi(db, list)
Patch by filtersupdate(db, schema, **kwargs)
Soft delete (if configured)delete(db, **kwargs)
Hard delete (always removes row)db_delete(db, **kwargs)
Countcount(db, **kwargs)
Boolean existence checkexists(db, **kwargs)

Avoiding N+1 — the most common mistake

FastCRUD has two mechanisms to prevent N+1 when fetching related data. Use one of them — never iterate.

1. Auto-detect relationships (zero config, default for v0.21+)

Set include_relationships=True on crud_router (or auto_detect_relationships=True on the FastCRUD method directly). FastCRUD inspects the SQLAlchemy mapper, builds the joins, and threads them through a single query.

python
crud_router(
    session=get_async_session,
    model=User,
    create_schema=UserCreate,
    update_schema=UserUpdate,
    select_schema=UserWithTier,           # schema includes nested tier field
    include_relationships=True,           # auto-include all relationships
    path="/users",
)

Or pass a list to include only specific relationships:

python
include_relationships=["tier", "department"]   # whitelist

Gotcha: One-to-many relationships are excluded by default because they can return unbounded data. Opt in explicitly:

python
include_relationships=True,
include_one_to_many=True,
default_nested_limit=10,    # cap nested rows per parent (uses SQL window function)

default_nested_limit uses row_number() OVER (PARTITION BY ...) at the database level — it does not fetch everything and slice in Python.

2. Manual JoinConfig (when you need explicit control)

For self-joins, aliases, custom join conditions, or per-join schemas:

python
from fastcrud import FastCRUD, JoinConfig

orders_with_user = await crud.get_multi_joined(
    db=session,
    joins_config=[
        JoinConfig(
            model=User,
            join_on=Order.user_id == User.id,
            join_prefix="user_",         # avoid column collisions
            schema_to_select=UserRead,
        ),
    ],
    offset=0,
    limit=20,
)

For one-to-many with per-parent capping:

python
JoinConfig(
    model=Article,
    join_on=Article.author_id == Author.id,
    relationship_type="one-to-many",
    sort_columns="created_at",
    sort_orders="desc",
    nested_limit=5,         # 5 most recent articles per author, computed in SQL
)

See references/joins.md for the full JoinConfig spec, polymorphic inheritance, and the auto-detection rules.


limit=None — the second biggest footgun

get_multi(...) / get_multi_joined(...) accept limit=None to fetch every matching row. This is the single biggest cause of latency cliffs and OOMs in real-world FastCRUD code.

Rule: limit=None is only safe when a WHERE clause domain-bounds the result to a known-small set. Otherwise, always pass an explicit numeric limit.

Safe — scoped by a WHERE clause

python
# OK: scoped to a single project → bounded by N(clips per project)
clips = await crud_clips.get_multi(db, limit=None, project_id=project.id)

# OK: scoped to a small known set of IDs
tiers = await crud_tiers.get_multi(db, limit=None, id__in=tier_ids)

Unsafe — unbounded growth

python
# BAD: grows linearly with the user table forever
users = await crud_users.get_multi(db, limit=None)

# GOOD: HTTP endpoint with a hard ceiling
limit = min(requested_limit, MAX_PAGE_LIMIT)
users = await crud_users.get_multi(db, offset=offset, limit=limit)

Pattern: sanity-check limit=None results

When limit=None is justified, log when results unexpectedly explode so drift is caught before it becomes an incident:

python
def handle_query_sanity(items: list, threshold: int, context: str) -> None:
    if len(items) > threshold:
        logger.warning("query growth: %d > %d threshold (%s)",
                       len(items), threshold, context)

entitlements = await crud_entitlements.get_multi(db, limit=None, user_id=user.id)
handle_query_sanity(entitlements["data"], threshold=100, context="user_entitlements")

Three-tier default

SurfaceDefault
HTTP list endpointlimit = min(requested, MAX_LIMIT)
Worker fetch of bounded child collectionExplicit cap (limit=RENDER_MAX_CLIPS)
Bulk fan-out across a large tableget_multi_by_cursor(limit=BATCH)

When NOT to use FastCRUD

FastCRUD is the right tool for per-row CRUD on a single model, optionally with relationship joins. Drop to raw SQLAlchemy when the query shape is anything else. In a typical app the split is roughly 80/20 — most data access is FastCRUD's territory, but the 20% that isn't is firmly SQLAlchemy's.

Query shapeUse instead
Aggregate roll-up (SUM, conditional COUNT FILTER WHERE, HAVING)select(func.sum(...), func.count().filter(...))
CTEsselect(...) with .cte()
GROUP BY with derived columns (Project + count(clips))select(Project, func.count(Clip.id)).group_by(Project.id)
Bulk UPDATE ... WHERE id IN (...) with column-expression RHSupdate(...).values(expires_at=Model.expires_at + interval)
Bulk INSERT (more than ~10 rows)db.execute(insert(Model), records)
Dialect-specific features (Postgres advisory locks, array_agg, JSONB)text("SELECT pg_advisory_xact_lock(...)") etc.
Anything you'd reach for joinedload / selectinload / contains_eager forFastCRUD JoinConfig — it covers this

crud_router itself is optional. Production codebases often use FastCRUD instances as the data-access layer behind hand-written FastAPI routes, skipping crud_router entirely so they can compose auth, validation, and business logic without subclassing EndpointCreator.

Anti-patterns to refactor on sight

  1. Per-ID loop calling crud.get() — classic N+1.

    python
    # BAD
    for tier_id in tier_ids:
        tier = await crud_tiers.get(db, id=tier_id)
    # GOOD — one query
    tiers = await crud_tiers.get_multi(db, id__in=tier_ids, limit=None)
  2. db.scalar(select(Model).where(...).exists()) — use await crud.exists(**filters).

  3. get_multi(limit=None) then Python filtering — push the filter into kwargs so the database does the work.

  4. crud.get() in a hot path without schema_to_select — selects every column. Pass a minimal schema to cut payload size.

  5. Loop of crud.create() — calls go one-at-a-time. For >10 rows use crud.upsert_multi(...) or raw db.execute(insert(Model), records).

  6. Hand-written select(...).where(...) for an OR group, a column comparison or an EXISTS (v0.23+) — pass the condition as where= to the FastCRUD method instead: await crud.get_multi(db, where=(Post.owner_id == me) | Post.published).


where= and row-level policies (v0.23+)

Every read and write takes where=: one SQLAlchemy condition or a list of them, AND-ed with the keyword filters. Use it for what keyword filters can't express — OR groups, column-to-column comparisons, .any() / .has() / exists() subqueries:

python
active_or_mine = await post_crud.get_multi(
    db, where=Post.published | (Post.owner_id == me), is_deleted=False
)

JoinConfig.where chooses which joined rows attach to each parent, never which parents come back (JoinConfig.filters does that); CountConfig.where chooses which rows are counted.

FastCRUD(Model, policy=PolicyConfig(...)) — or crud.with_policy(policy) per request — restricts every operation the way Postgres row-level security does: rows outside the policy behave as absent, writes can only reach rows the caller can read, and create_check / update_check validate written rows. crud_router(policy=...) resolves a policy per request and turns AccessDenied into a 403. Read references/policies.md before writing policy code.


Filter syntax

FastCRUD accepts filters as keyword arguments on every read/update/delete method, and via FilterConfig for crud_router's GET /list endpoint.

Operator suffix on the field name

The operator goes after a double underscore:

python
await crud.get_multi(db, price__gte=10, name__ilike="%admin%", id__in=[1, 2, 3])
SuffixSQLNotes
(none)=bare field name is equality
__eq=explicit form
__ne<>
__gt __gte> >=
__lt __lte< <=
__inIN (...)value must be list/tuple/set
__not_inNOT IN (...)same
__betweenBETWEEN a AND bvalue must be a 2-element sequence
__likeLIKEcase-sensitive
__ilikeILIKEcase-insensitive (Postgres)
__startswith __endswith __containssubstring match (auto-wraps with %)pass the bare substring — do NOT add % yourself
__is __is_notIS IS NOTfor NULL checks
__matchengine-specific full-textdepends on dialect

Joined filters

Walk relationships with .:

python
await crud.get_multi_joined(db, **{"tier.name__eq": "premium"})

Or, equivalently, build the kwargs dict literal-style. In FilterConfig:

python
FilterConfig({
    "name__ilike": None,           # query string param: ?name__ilike=foo
    "price__gte": None,
    "tier.name": None,             # joined filter — auto-includes the tier relationship
})

Bool coercion

?filter=false is correctly parsed (case-insensitive: False, FALSE, 0, true, TRUE, 1). No need to pre-coerce.

See references/filters.md for FilterConfig deep-dive, Depends(...) filters (filter by current user automatically), and custom operator registration.


Return semantics — read carefully

The biggest footgun. Several methods have non-obvious defaults that changed in v0.20:

create()

  • Without schema_to_select: returns None (since v0.20.0 — was the model in earlier versions).
  • With schema_to_select: returns a dict.
  • With schema_to_select + return_as_model=True: returns a Pydantic instance.
python
await crud.create(db, UserCreate(...))                                     # → None
await crud.create(db, UserCreate(...), schema_to_select=UserRead)          # → dict
await crud.create(db, UserCreate(...), schema_to_select=UserRead,
                  return_as_model=True)                                    # → UserRead

return_as_model=True requires schema_to_select (else raises ValueError).

get() / get_multi() / get_joined() / etc.

Same pattern: return_as_model=True requires schema_to_select. Without schema_to_select, all columns are returned as a dict.

schema_to_select propagates the subclass type (v0.22+)

If you pass a subclass override per-call, the return type narrows correctly — no manual cast() needed:

python
class UserAdminRead(UserRead):
    role: str

result = await crud.get(db, id=1, schema_to_select=UserAdminRead, return_as_model=True)
# result is typed as UserAdminRead | None, not UserRead | None

update(return_columns=...) must be a list (v0.22+)

return_columns=True raises ValueError. Pass a list:

python
await crud.update(db, schema=UserUpdate(name="X"), return_columns=["id", "name"], id=1)

commit=False for transactions

All write methods take commit=False. Use it when chaining multiple operations under a single transaction; commit once at the end with await db.commit().


Soft delete

Configure at the FastCRUD level:

python
crud = FastCRUD(User, is_deleted_column="is_deleted", deleted_at_column="deleted_at")

await crud.delete(db, id=1)       # soft: sets is_deleted=True, deleted_at=now
                                  #       (requires both columns on the model)
await crud.db_delete(db, id=1)    # hard: actual DELETE — always removes the row

Reads do NOT auto-filter soft-deleted rows. This is the most common surprise — you have to filter explicitly:

python
active = await crud.get_multi(db, is_deleted=False)

If you want every read to exclude soft-deleted rows automatically, wrap FastCRUD in a subclass that overrides get/get_multi/etc. to add the filter, or pass is_deleted=False as a dependency-based default via FilterConfig on crud_router.


Gotchas (read these before writing code)

  1. limit=None fetches every matching row. Only safe when a WHERE clause domain-bounds the result. See the dedicated section.
  2. Async session only. All methods require AsyncSession. Sync Session support for count()/exists() is in flight (PR #333) but not merged.
  3. One-to-many relationships excluded from auto-detect. Add include_one_to_many=True and set default_nested_limit (or nested_limit per JoinConfig).
  4. Filter operator separator is __ (two underscores). price_gte is just a column name price_gte; price__gte is price >= ....
  5. create() returns None by default (no schema_to_select). Don't expect the model back.
  6. return_as_model=True without schema_to_select raises. Always pair them.
  7. update(return_columns=True) raises since v0.22.0. Use a list of column names.
  8. Joined filters need . not __ for the relationship part. "tier.name__eq", not "tier__name__eq".
  9. session parameter on crud_router is a callable (a FastAPI dependency that yields/returns an AsyncSession), not a session instance.
  10. include_relationships and joins_config are mutually exclusive on crud_router. Pick one.
  11. Joined-table polymorphic inheritance is supported. v0.22.0 fixed auto-aliasing when primary and joined share a base table; v0.22.2 fixed inherited columns missing from create() responses.
  12. SQLModel joined-table inheritance is fragile — redeclaring id in a subclass breaks the SQLModel metaclass mapping. Stick to plain SQLAlchemy if you need polymorphism.
  13. Python 3.10+ required. Python 3.14 works since v0.22.1 (earlier versions crashed at import under PEP 649).
  14. update / delete / db_delete refuse to run with no filters (v0.23+), including where=[]. Pass where=true() to reach every row on purpose.
  15. A condition naming a model the query doesn't join raises ValueError (v0.23+) instead of returning a cartesian product. Join the model, or use .any() / .has().
  16. db_delete raises NoResultFound when nothing matches (v0.23+), like delete and update.
  17. where and server_data are reserved keyword arguments (v0.23+): where on every read and write, so a column named where can't be filtered by keyword (use where=Model.where == value); server_data only on update, the one method that takes both it and keyword filters.

When to drill into references

Load these on demand:

  • references/methods.md — full signatures and overloads for every FastCRUD method
  • references/filters.md — every operator, FilterConfig, dependency-based filters, custom operators
  • references/joins.md — JoinConfig fields, auto-detection rules, nested_limit mechanics, polymorphism
  • references/pagination.md — offset vs cursor pagination, paginated_response, CursorPaginatedRequestQuery
  • references/endpoints.md — crud_router full signature, EndpointCreator subclassing, included_methods, per-method dependencies, soft delete, custom endpoint names
  • references/policies.md — PolicyConfig, AccessDenied, with_policy, column rules, server_data, upserts and endpoints under a policy (v0.23+)

SQLModel notes (when the project uses SQLModel instead)

The library works the same way; substitute the model definition:

python
from sqlmodel import SQLModel, Field

class User(SQLModel, table=True):
    __tablename__ = "user"
    id: int | None = Field(default=None, primary_key=True)
    name: str
    email: str = Field(unique=True)
    tier_id: int | None = Field(default=None, foreign_key="tier.id")

Schemas can also be SQLModel (without table=True) instead of BaseModel. The CRUD layer is identical because SQLModel inherits from SQLAlchemy. One caveat:

  • Joined-table inheritance is unreliable — see gotcha #11. For polymorphic models, drop to plain SQLAlchemy.
  • Conditions need col() for type checkers (v0.23+) — SQLModel types Hero.age > 18 as bool, so write col(Hero.age) > 18 in where=, JoinConfig.where and policy rules.

Bundled files

The model reads these on demand while the skill is loaded. They are exposed as readable files and are never executed.

Frequently asked questions

What does the Fastcrud AI skill do?

Use when building or modifying CRUD endpoints with FastCRUD (the `fastcrud` PyPI package) in a FastAPI project — covers `FastCRUD`, `crud_router`, `EndpointCreator`, `FilterConfig`, `JoinConfig`, auto-relationship detection, the filter operator syntax (`__gte`, `__in`, `__ilike`, etc.), cursor pagination, soft delete, and how to avoid N+1 queries when fetching related data. Activate when the user mentions FastCRUD, `crud_router`, `FastCRUD()`, `FilterConfig`, `JoinConfig`, or asks how to build CRUD endpoints / generate REST APIs from SQLAlchemy or SQLModel models, even if the user doesn't n...

Why use Fastcrud on TypingMind?

Because you install it once and use it with any model. Fastcrud is plain Markdown rather than provider-specific code, so the same skill runs on GPT-5, Claude, Gemini, Grok, or a local model — and you can switch model mid-chat without it breaking. TypingMind runs on your own API keys, so you pay providers directly instead of a per-seat subscription, and your skills and chats stay in your own storage.

How do I install Fastcrud in TypingMind?

Open Plugins → Skills → Install from GitHub in TypingMind and paste https://github.com/benavlabs/fastcrud/tree/main/fastcrud/.agents/skills/fastcrud. TypingMind reads its SKILL.md and bundles its files and installs it as a skill you can enable per chat.

Which AI models can use Fastcrud?

Any model you connect in TypingMind. AI skills are plain Markdown instructions rather than provider-specific code, so GPT, Claude, Gemini, Grok, and local models can all load this skill when a request matches it.

How many AI models can I use with Fastcrud?

As many as you like. As long as a model supports skills, you can use Fastcrud with it — GPT, Claude, Gemini, Grok, DeepSeek, Mistral, Llama and more — all on TypingMind with your own API keys.

Is the Fastcrud AI skill free?

Yes. It is published on GitHub by benavlabs under the MIT license. You only pay your own AI provider for the tokens you use.

What are AI skills?

An AI skill is a reusable instruction bundle that teaches an AI model how to do one specific task. It follows the open Agent Skills format: a SKILL.md file with a name and description, plus any scripts, templates or reference files the model may need. The model reads the instructions only when your request matches the skill, so an installed skill costs nothing until it is used.

How are AI skills different from plugins or MCP servers?

A plugin or MCP server gives a model new tools to call — code that runs somewhere and returns a result. An AI skill gives the model knowledge and process instead: how to approach a task, which steps to follow, what good output looks like. Skills are plain Markdown, so they need no server, no API key and no runtime, and they work with any model.

View all

Set up your own AI workspace now

Get notified about new features and future giveaways by subscribing to our newsletter 👇