How it compares
There are three common ways to isolate tenants in a FastAPI/SQLAlchemy application. They differ in one dimension that matters more than all the others: where enforcement lives.
Manual WHERE tenant_id = … | with_loader_criteria | fastapi-rls (PostgreSQL RLS) | |
|---|---|---|---|
| Enforcement layer | Every call site | SQLAlchemy ORM session | The database itself |
| Covers ORM queries | Only if you remember | Yes | Yes |
Covers raw SQL (text(…)) | Only if you remember | No | Yes |
| Covers non-ORM / Core statements | Only if you remember | Partially¹ | Yes |
| Covers other clients (psql, BI tools, cron jobs)² | No | No | Yes |
Covers INSERT/UPDATE writes | Only if you remember | Read-focused¹ | Yes (WITH CHECK) |
| Survives a forgotten filter | No — that's the leak | Mostly | Yes |
| Setup cost | None upfront, unbounded ongoing | Low | Migration + role setup |
| Requires PostgreSQL | No | No | Yes |
¹ with_loader_criteria hooks SQLAlchemy's ORM execution events. It's a solid
mechanism for ORM SELECTs, but statements that bypass ORM entity resolution —
textual SQL, some Core constructs, bulk operations depending on configuration —
bypass the filter too, silently.
² Any connection using the same non-superuser role is constrained by RLS, including ones that never import your application code.
The failure mode each one has
Manual WHERE clauses fail one forgotten filter at a time. The cost isn't
writing them — it's that correctness depends on every current and future
developer remembering, on every query, forever. Code review is your only
enforcement mechanism.
with_loader_criteria centralizes the filter, which is a real improvement.
But it enforces in the application process: raw SQL escapes it, other services
touching the same database escape it, and a developer can disable it per-query.
If your threat model is "we might write a bad query", it helps; if it's "no
code path may ever return another tenant's rows", it can't make that guarantee.
PostgreSQL RLS moves the predicate into the database. A query with no
tenant context returns no rows — not all rows. The trade-offs are real,
though: you're committed to PostgreSQL, you must connect as a non-superuser
role (superusers and BYPASSRLS roles skip RLS entirely — see the
security model), and policies add a predicate
to every query on protected tables, which you should
index for.
What about other libraries?
- Permission libraries for FastAPI (e.g.
fastapi-permissions) authorize requests in application code — useful, but orthogonal: they decide whether an endpoint runs, not which rows a query can see. - Tenancy libraries that filter at the ORM/session layer share
with_loader_criteria's enforcement boundary: application code in, raw SQL and external clients out. - django-rls and flask-rls are sibling projects by the same author, applying the same database-enforced approach to Django and Flask.
When not to use fastapi-rls
Honest cases where the alternatives win:
- You're not on PostgreSQL. RLS as implemented here is PostgreSQL-specific.
- Single-tenant app, no per-user row scoping. You don't have the problem RLS solves.
- You can't control the database role. On platforms that force superuser
connections, RLS silently doesn't apply — a
with_loader_criteriafilter is then better than a false sense of safety. - Extremely hot paths where the policy predicate shows up in profiles and you've measured it: hand-tuned queries may be preferable for those specific paths.
For everything else multi-tenant on FastAPI + PostgreSQL, database-enforced isolation is the option that doesn't depend on nobody ever making a mistake. Start with the quick start.
Written and maintained by Kuldeep Pisda · source on GitHub