Every CRM I've ever used has made me angry.
Not the "this button is in the wrong place" kind of angry. The "I'm paying $65/user/month and I can't even export my own data" kind of angry.
I'm Ashwin Kumar. I ran MicroPyramid, a software consulting firm with 100+ engineers and clients across the US, Australia, Israel, and the UK. Over a decade, we built custom CRMs, ERPs, and SaaS platforms for dozens of companies. And every single time, the conversation started the same way:
"We're using Salesforce/HubSpot/Zoho, but it's too expensive, too locked-in, and we can't customize it."
So we'd build them something custom. Again and again. After the fifth time building essentially the same multi-tenant CRM from scratch, I thought — why not build it once, properly, and open-source it?
That became Django-CRM (now BottleCRM). It's got 2,100+ stars on GitHub, it's MIT-licensed, and it runs in production. But the most interesting technical decision — the one I want to talk about — is how we handle multi-tenancy.
The Multi-Tenancy Problem
If you're building any SaaS, you need multi-tenancy. Multiple organizations sharing the same application, each seeing only their own data. There are three common approaches:
- Separate databases per tenant — bulletproof isolation, operational nightmare
-
Schema-per-tenant (the
django-tenantsapproach) — each tenant gets a PostgreSQL schema, shared database - Shared schema with Row-Level Security — one schema, one set of tables, PostgreSQL enforces isolation at the row level
When I started, django-tenants was the obvious choice. It's mature, well-documented, and the Django community loves it. I almost went with it.
Then I did the math.
Why Schema-Per-Tenant Broke Down
With django-tenants, every migrate runs against every schema. Got 500 tenants? That's 500 migration runs. Add a column? Hope you've got time.
Here's what we actually hit in production with schema-per-tenant on a previous project:
- Migration time scaled linearly with tenant count. 200 tenants meant 45+ minute deploys.
-
Connection pooling was painful. PgBouncer doesn't love
SET search_pathon every request. We burned hours debugging stale connections. -
Cross-tenant queries were nearly impossible. Want to build an admin dashboard showing all tenants? You're writing raw SQL with
UNION ALLacross schemas. - Django's ORM assumed one schema. We were constantly fighting the framework instead of using it.
RLS flips the model entirely.
Row-Level Security: Let PostgreSQL Do the Hard Work
With RLS, every table has a policy that says: "You can only see rows where org_id matches the current session variable." PostgreSQL enforces this at the engine level — not in your application code, not in your ORM, not in a middleware hack. At the database engine level.
Here's what an actual RLS policy looks like for our leads table:
-- Enable RLS on the table
ALTER TABLE leads_lead ENABLE ROW LEVEL SECURITY;
-- Force RLS even for table owners
ALTER TABLE leads_lead FORCE ROW LEVEL SECURITY;
-- The policy: users can only see rows belonging to their org
CREATE POLICY tenant_isolation_policy ON leads_lead
USING (org_id = current_setting('app.current_org')::INTEGER);
-- Separate policy for INSERT (ensure users can only create rows for their org)
CREATE POLICY tenant_insert_policy ON leads_lead
FOR INSERT
WITH CHECK (org_id = current_setting('app.current_org')::INTEGER);
That's it. Once this policy exists, PostgreSQL will never return a row from another tenant, even if your application code has a bug. Even if someone writes a raw SQL query. Even if a junior developer forgets to add .filter(org=request.org). The database says no.
This is defense in depth that actually works.
How Django Talks to RLS
The magic glue is a middleware that sets the PostgreSQL session variable on every request. Here's the actual pattern:
# middleware/tenant.py
class TenantMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
# Extract org from the authenticated user's JWT
org_id = getattr(request, 'org', None)
if org_id:
from django.db import connection
with connection.cursor() as cursor:
cursor.execute(
"SET app.current_org = %s", [str(org_id)]
)
response = self.get_response(request)
# Reset to prevent connection pool leakage
if org_id:
with connection.cursor() as cursor:
cursor.execute("RESET app.current_org")
return response
The beauty: your views and serializers don't need to know about tenancy at all. The ORM just works. Querysets automatically return only the current tenant's data because PostgreSQL filters at the engine level.
# views.py — no tenant filtering needed!
class LeadViewSet(viewsets.ModelViewSet):
serializer_class = LeadSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
# This returns ALL leads — but RLS ensures
# only the current org's leads come back
return Lead.objects.all().order_by('-created_at')
def perform_create(self, serializer):
# org_id is set automatically, and RLS validates
# that it matches the session variable
serializer.save(
org_id=self.request.org.id,
created_by=self.request.user
)
No .filter(org=request.org) scattered across 200 views. No chance of forgetting it in one place and leaking data. The database is the single source of truth for tenant isolation.
The Full Stack: Why Django + SvelteKit
A lot of people ask why not Next.js or React. Short answer: I've shipped production apps with all of them, and for a CRM, SvelteKit wins on three fronts:
- Bundle size. CRMs have dozens of forms, tables, and modals. Svelte compiles away the framework — our JS bundle is ~40% smaller than the React equivalent we prototyped.
-
Reactivity without boilerplate. Svelte 5's runes (
$state,$derived) are cleaner thanuseState+useEffect+useMemo. For a data-heavy app, this matters. - SvelteKit's routing is file-based and simple. No fighting with App Router vs Pages Router. No server components confusion. It just works.
The full stack:
| Layer | Tech | Why |
|---|---|---|
| Backend | Django 5 + DRF | Battle-tested, ORM is excellent for complex data models |
| Frontend | SvelteKit 2 + Svelte 5 + TailwindCSS 4 | Fast, small, great DX |
| Database | PostgreSQL 14+ | RLS, JSONB, full-text search — all in one |
| Auth | JWT (access + refresh tokens) | Stateless, works with mobile apps |
| Background jobs | Celery + Redis | Email, notifications, data imports |
| Deployment | Docker Compose | One command: docker compose up
|
Performance: RLS vs Schema-Per-Tenant
Let's talk numbers. These are from our benchmarks with realistic data (10K-100K rows per tenant):
Query performance: Virtually identical. PostgreSQL's query planner treats RLS policies like additional WHERE clauses. With a proper index on org_id, there's negligible overhead — we measured <1ms difference on 95th percentile queries.
Migration speed: This is where RLS dominates. One schema means one migration run, regardless of tenant count. 500 tenants? Same 3-second migration as 1 tenant.
Connection pooling: Standard PgBouncer works perfectly. No search_path switching, no stale state. Each request just sets a session variable and resets it.
Disk usage: Shared indexes across all tenants. Schema-per-tenant duplicates every index for every tenant. At 500 tenants with 15 tables, that's 7,500 indexes vs. 15.
The tradeoff? Schema-per-tenant gives you harder isolation boundaries. If a tenant needs a custom column or a different schema, schema-per-tenant handles that natively. With RLS, every tenant shares the same schema. For a CRM where every tenant uses the same data model, this isn't a limitation — it's a feature.
Why MIT and Not AGPL
This was a deliberate choice. AGPL would have "protected" the project by forcing anyone who modifies it to open-source their changes. But in practice:
- AGPL scares enterprises. Legal teams see AGPL and close the tab. We wanted adoption.
- MIT builds trust. You can fork it, modify it, sell it, host it — we don't care. Use it however you want.
- Community over control. The projects with the healthiest ecosystems — React, Vue, Django itself — are permissively licensed. We wanted contributors, not compliance officers.
Our bet: if the product is good enough, people contribute because they want to, not because a license forces them. So far, that bet is paying off.
Lessons From Running This With Real Clients
After years of running multi-tenant CRM deployments, here's what I wish someone had told me:
1. Always reset the session variable. If you set app.current_org and don't reset it, connection pools will serve the wrong tenant's data to the next request. We learned this the hard way. Always reset in a finally block or middleware teardown.
2. Superuser bypasses RLS by default. PostgreSQL RLS policies don't apply to superusers or table owners unless you use FORCE ROW LEVEL SECURITY. We missed this initially and wondered why our admin panel showed all data. Add FORCE to every table.
3. Test with multiple tenants from day one. Write a test that creates two orgs, inserts data for both, and asserts that each org can only see its own. Run it in CI. Never skip it.
4. Audit logging needs the org context too. Your audit trail should capture which org performed which action. Don't rely on application-level logging alone — use PostgreSQL triggers that read current_setting('app.current_org').
5. Data exports are trivially simple. Need to export all of tenant 42's data? SET app.current_org = '42'; COPY leads_lead TO '/tmp/leads.csv' CSV HEADER; — RLS does the filtering for you.
Getting Started
BottleCRM runs with a single command:
# docker-compose.yml (simplified)
services:
backend:
build: ./backend
environment:
- DATABASE_URL=postgres://crm:crm@db:5432/bottlecrm
- REDIS_URL=redis://redis:6379
- SECRET_KEY=${SECRET_KEY}
depends_on:
- db
- redis
frontend:
build: ./frontend
environment:
- PUBLIC_API_URL=http://backend:8000
ports:
- "3000:3000"
db:
image: postgres:16
environment:
- POSTGRES_DB=bottlecrm
- POSTGRES_USER=crm
- POSTGRES_PASSWORD=crm
volumes:
- pgdata:/var/lib/postgresql/data
redis:
image: redis:7-alpine
celery:
build: ./backend
command: celery -A crm worker -l info
depends_on:
- db
- redis
volumes:
pgdata:
git clone https://github.com/MicroPyramid/Django-CRM.git
cd Django-CRM
docker compose up
That's it. Open localhost:3000, create an account, and you've got a production-grade CRM running locally.
Ship It
Building BottleCRM taught me that the best multi-tenancy strategy isn't the most clever one — it's the one that lets PostgreSQL do what it's good at. RLS pushes tenant isolation into the database engine where it belongs. Your application code stays clean, your deployments stay fast, and your data stays safe.
If you're building a multi-tenant Django app, seriously consider RLS before reaching for django-tenants. It's less magical, more explicit, and PostgreSQL has been battle-testing this feature since version 9.5.
⭐ Star Django-CRM on GitHub — 2,100+ stars and growing
🚀 Try the live demo at bottlecrm.io — free, no credit card
🤝 Contribute — we're looking for help with the mobile app (Flutter), analytics dashboard, and documentation. Check the issues tab.
Built with 🐍 Django + ⚡ SvelteKit + 🐘 PostgreSQL. MIT Licensed. Free forever.
Top comments (0)