SaaS multiempresa con PostgreSQL schemas y django-tenants: caso AulaSaaS
Un SaaS de e-learning que da servicio a varios colegios con una sola instalación.
Tenants, dominios, usuarios y facturación viven en public.
Cursos, alumnos, notas y asistencia: datos de cada cliente.
acme.tuapp.com y gea.tuapp.com sirven al mismo código.
Tenants, aislamiento y PostgreSQL schemas
Un cliente lógico cuyos datos deben mantenerse separados del resto. En AulaSaaS, cada colegio.
Una sola instalación atiende a muchos tenants, compartiendo infraestructura pero aislando datos.
Sin un modelo claro aparecen dos extremos, y ambos duelen.
Filtros manuales (where tenant_id=...) en cada query. Un olvido = una fuga entre colegios.
Aislamiento perfecto, pero el coste y la operación se multiplican por cada tenant.
Cada cliente con su instalación y su base de datos: versiones que se separan y operación multiplicada.
| Shared schema | Schema-per-tenant | DB-por-tenant | |
|---|---|---|---|
| Aislamiento | Bajo (filtros) | Alto | Total |
| Coste | Mínimo | Bajo | Alto |
| Complejidad | Baja | Media | Alta |
| Migraciones | Una | Por schema | Por base |
| Ideal para | muchos tenants, datos simples | B2B, aislamiento por cliente | regulación estricta |
publicCada patrón se posiciona distinto: más aislamiento suele implicar más operación.
Valores cualitativos para comparar patrones; no es un benchmark.
Un schema es un espacio de nombres dentro de una base de datos: permite repetir el nombre de tabla sin conflictos.
search_path = acme, public el ORM consulta acme primero y cae a public para lo compartido.aulasasscursos y alumnos, sin verse entre sí.Si eliges shared schema, no confíes solo en el filtrado del ORM: refuérzalo en la base de datos.
ALTER TABLE cursos ENABLE ROW LEVEL SECURITY;
CREATE POLICY aislamiento ON cursos
USING (tenant_id = current_setting('app.tenant_id')::int);
WHERE, no devuelve datos de otro tenant.WHERE tenant_id=... olvidado en shared schema.search_path mal fijado que apunta al schema equivocado.Schema por tenant en Django, de la teoría al código
El paquete de referencia para multitenancy con schemas de PostgreSQL en Django.
Fork mantenido por la comunidad. Compatible con Django 5 y psycopg 3.
Casi no cambias tu código: el schema se fija por petición.
Usa schemas, así que requiere PostgreSQL (no SQLite/MySQL).
django-tenant-schemas, pero está sin mantenimiento. Hoy instala django-tenants.public.El subdominio decide qué schema se consulta, sin tocar las vistas.
Django 5, PostgreSQL y el driver psycopg 3.
Cambia el ENGINE por el backend de django-tenants.
DATABASES = {
"default": {
"ENGINE": "django_tenants.postgresql_backend",
"NAME": "aulasass",
"USER": "aulasass",
"PASSWORD": "...",
"HOST": "localhost",
"PORT": "5432",
}
}
search_path.MIDDLEWARE = [
"django_tenants.middleware.main"
".TenantMainMiddleware",
# ... el resto
]
ALLOWED_HOSTS = [
"localhost", ".localhost",
".tuapp.com",
]
.localhost habilita los subdominios en desarrollo.SHARED_APPS = [
"django_tenants", # obligatorio
"customers", # client + domain
"django.contrib.contenttypes",
"django.contrib.auth",
"django.contrib.sessions",
"django.contrib.admin",
]
TENANT_APPS = [
"django.contrib.contenttypes",
"cursos", "alumnos", # datos por tenant
]
INSTALLED_APPS = list(SHARED_APPS) + [
a for a in TENANT_APPS if a not in SHARED_APPS
]
SHARED_APPS van al schema public; TENANT_APPS se crean en cada schema de tenant.TENANT_MODEL = "customers.Client"
TENANT_DOMAIN_MODEL = "customers.Domain"
DATABASE_ROUTERS = (
"django_tenants.routers.TenantSyncRouter",
)
from django_tenants.models import TenantMixin, DomainMixin
from django.db import models
class Client(TenantMixin):
nombre = models.CharField(max_length=100)
plan = models.CharField(max_length=20, default="basico")
auto_create_schema = True
class Domain(DomainMixin):
pass
TenantMixin aporta schema_name; DomainMixin aporta domain e is_primary. Añade tus propios campos.Tu dominio principal también es un tenant, respaldado por el schema public.
from customers.models import Client, Domain
public = Client(schema_name="public", nombre="AulaSaaS")
public.save()
Domain.objects.create(
domain="tuapp.com", tenant=public, is_primary=True,
)
tuapp.com responde 404: el middleware no encuentra a quién servir.migrate a secas: migrate_schemas aplica cada app en el schema que le toca.Con cientos de tenants, migrar uno a uno tarda demasiado.
El tiempo crece con el número de schemas; el ejecutor en paralelo lo reparte entre procesos.
Cifras ilustrativas para mostrar la tendencia; no es un benchmark.
python manage.py create_tenant \
--schema_name=acme \
--name=Acme \
--domain=acme.localhost
tenant = Client(
schema_name="acme", nombre="Acme",
)
tenant.save()
Domain.objects.create(
domain="acme.localhost", tenant=tenant,
)
tenant.save() crea el schema y ejecuta las migraciones: el tenant queda listo al instante.Crear un schema y migrarlo tarda; no lo hagas en la petición de registro.
from celery import shared_task
from customers.models import Client
@shared_task
def provisionar(tenant_id):
tenant = Client.objects.get(id=tenant_id)
tenant.create_schema(check_if_exists=True)
# sembrar datos iniciales, admin, plan...
pendiente hasta que el schema esté listo: el middleware debe ignorar tenants sin terminar.from django.db import connection
def inicio(request):
tenant = request.tenant # objeto Client
schema = connection.schema_name # "acme" o "public"
nombre = request.tenant.nombre
return render(request, "inicio.html", {"colegio": nombre})
request.tenant es útil para branding y features; connection.schema_name para lógica de datos.Tenants, dominios, usuarios globales, planes, facturación, catálogos.
Cursos, alumnos, notas, asistencia, configuración y branding del colegio.
public.migrate en vez de migrate_schemas.TenantMainMiddleware o ponerlo fuera de orden.public y recibir 404 en el dominio raíz.SHARED_APPS.Lo que separa una demo de un producto
django-tenants aporta clases de test que crean un tenant temporal.
from django_tenants.test.cases import TenantTestCase
class CursoTest(TenantTestCase):
def test_crear_curso(self):
Curso.objects.create(nombre="Matemática")
self.assertEqual(Curso.objects.count(), 1)
TransactionTestCase: son más lentos y no se paralelizan igual que los normales.Cada colegio quiere su logo, su subdominio y sus funciones.
class TenantConfig(models.Model):
tenant = models.OneToOneField(
"customers.Client", on_delete=models.CASCADE,
)
plan = models.CharField(max_length=20, default="basico")
logo = models.ImageField(upload_to="logos/")
gamificado = models.BooleanField(default=False)
public. Datos de negocio → en el schema del tenant.Las subidas deben aislarse igual que los datos: una ruta por tenant.
from django.db import connection
def ruta_por_tenant(instance, filename):
schema = connection.schema_name
return f"{schema}/documentos/{filename}"
class Documento(models.Model):
archivo = models.FileField(upload_to=ruta_por_tenant)
Un worker no tiene subdominio: hay que decirle explícitamente en qué schema trabajar.
from django_tenants.utils import schema_context
@shared_task
def enviar_reporte(schema_name, curso_id):
with schema_context(schema_name):
# aquí el ORM usa el schema del tenant
generar_pdf(curso_id)
schema_name a la tarea y envuélvela en schema_context: sin eso, usa public.search_path concatenando texto del usuario: es una inyección SQL.Un schema por tenant no multiplica conexiones, pero sí el trabajo de bootstrap.
Reutiliza conexiones; evita reconectar en cada petición.
Pool del lado de la base de datos para muchos workers.
Cada tabla replicada necesita sus índices; cuida el tamaño.
Una sola app atiende a todos los tenants; los recursos se comparten o se separan donde importa.
schema_name en cada línea.Si cobras por uso, necesitas medirlo por tenant y limitarlo.
Límites por plan: nº de alumnos, almacenamiento, funciones.
Registra consumo por tenant para facturar y avisar.
Un tenant impago se bloquea sin afectar al resto.
public y condiciona el acceso al schema del tenant.Derecho al olvido y portabilidad, aplicados por tenant.
Volcar todo el schema de un tenant a JSON/CSV bajo demanda.
Borrar el tenant y su schema: auto_drop_schema o comando explícito.
pg_dump de toda la basepg_dump -n acmepublic creado y sirviendo.pendiente.TenantTestCase.Multitenancy fuera de Django y lecciones reales
Igual que django-tenants: search_path por petición. Reutiliza el mismo esquema SQL.
Cada tabla lleva tenant_id; Postgres lo refuerza con RLS.
from sqlalchemy import event
from contextvars import ContextVar
tenant_ctx = ContextVar("schema", default="public")
@event.listens_for(engine, "checkout")
def set_schema(dbapi_conn, record):
schema = tenant_ctx.get()
dbapi_conn.cursor().execute(
f'SET search_path TO "{schema}", public'
)
schema contra una lista blanca antes de interpolarlo: aquí también hay riesgo de inyección.class Curso(Base):
__tablename__ = "cursos"
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[int] = mapped_column(index=True)
nombre: Mapped[str]
# RLS en Postgres usa current_setting('app.tenant_id')
SET app.tenant_id = ... y Postgres filtra aunque olvides el WHERE.| Django + django-tenants | FastAPI + SQLAlchemy | |
|---|---|---|
| Multitenancy | Listo (schemas) | Manual (contexto) |
| Admin | Incluido | No incluido |
| Velocidad | Buena | Muy alta |
| Ecosistema | Enorme | Moderno, asíncrono |
| Ideal para | SaaS con back-office | APIs y servicios |
Plataforma multicliente de aprendizaje con gamificación, construida sobre estas ideas.
Programa de formación con retos y puntos.
Sectores y equipos con seguimiento propio.
Colegio con alumnos, cursos y gamificación.
django_tenants.postgresql_backendTenantMainMiddleware primeroSHARED_APPS / TENANT_APPSTENANT_MODEL + TENANT_DOMAIN_MODELmigrate_schemas --shared|--tenantschema_context(...)TenantTestCasejoseamaya/multitenant es un ejemplo mínimo para arrancar tu proyecto.Elige tu patrón, separa por schema y automatiza la operación.