Quién les habla

José Miguel Amaya — SaaS y multitenancy

José Miguel Amaya
  • CTO y socio fundador de Xprende Tech. Full Stack Engineer en Efilm Online.
  • Construí una plataforma SaaS de e-learning multiempresa (Xprende) con Django.
  • Especialista en Python, Django/FastAPI e IA (LangChain, LangGraph, RAG).
  • Fundador de Piura AI, Tallanix, Python Piura y VICUX.
comunidad
empresa
Agenda · 60 minutos

El recorrido de hoy

  • 01 Fundamentos: tenants y aislamiento 10 min
  • 02 django-tenants: schema por tenant 20 min
  • 03 Operación: migraciones, tests, seguridad 18 min
  • 04 FastAPI/SQLAlchemy y mi experiencia 12 min
✓
Todo gira alrededor de un solo caso: AulaSaaS, una plataforma de e-learning que atiende a varios colegios.
El caso

AulaSaaS

Un SaaS de e-learning que da servicio a varios colegios con una sola instalación.

compartido
Planes y cuentas

Tenants, dominios, usuarios y facturación viven en public.

por tenant
Colegio

Cursos, alumnos, notas y asistencia: datos de cada cliente.

entrega
Subdominios

acme.tuapp.com y gea.tuapp.com sirven al mismo código.

tenant: acme tenant: gea schema: public PostgreSQL
Vocabulario

¿Qué es un tenant?

tenant
Inquilino

Un cliente lógico cuyos datos deben mantenerse separados del resto. En AulaSaaS, cada colegio.

multitenancy
Compartir

Una sola instalación atiende a muchos tenants, compartiendo infraestructura pero aislando datos.

i
La clave no es "muchos clientes", sino cómo separas sus datos. De eso salen los 3 patrones.
El problema

SaaS multiempresa

Sin un modelo claro aparecen dos extremos, y ambos duelen.

si comparto todo
Una sola tabla

Filtros manuales (where tenant_id=...) en cada query. Un olvido = una fuga entre colegios.

si aíslo todo
Una BD por cliente

Aislamiento perfecto, pero el coste y la operación se multiplican por cada tenant.

!
El objetivo es el punto medio: aislamiento fuerte sin multiplicar la operación.
El extremo

Database-per-tenant sin automatización

Cada cliente con su instalación y su base de datos: versiones que se separan y operación multiplicada.

Instalaciones y versiones dispersas app v2.1 BD app v1.9 BD app v2.3 BD app v1.5 BD app v2.2 BD app v2.3 BD
!
Cada versión distinta es un despliegue, una migración y un backup por separado: el coste crece con cada cliente.
Las tres opciones

Los 3 patrones de multitenancy

Los tres patrones de multitenancy Shared schema base de datos schema public cursos · alumnos · notas todas con tenant_id aislamiento bajo · coste mínimo Schema-per-tenant base de datos public acme gea compartido cursos alumnos cursos alumnos aislamiento alto · coste bajo DB-por-tenant BD acme cursos BD gea cursos BD kepler cursos aislamiento total · coste alto
✓
PostgreSQL es ideal para el patrón 2: los schemas son como carpetas dentro de la misma base de datos.
Elegir con criterio

Comparativa

Shared schemaSchema-per-tenantDB-por-tenant
AislamientoBajo (filtros)AltoTotal
CosteMínimoBajoAlto
ComplejidadBajaMediaAlta
MigracionesUnaPor schemaPor base
Ideal paramuchos tenants, datos simplesB2B, aislamiento por clienteregulación estricta
Decisión

Cómo elegir

Nº de tenants cientos o miles → schema-per-tenant
Aislamiento requisito legal fuerte → DB-por-tenant
Datos comunes catálogos globales → schema public
Equipo pequeño → minimiza la operación
Personalización por cliente → favorece separar
Coste prioridad → shared schema + RLS
i
Y siempre: puedes migrar después. Empezar compartido y pasar a schemas es más fácil que al revés.
Ilustrativo

Coste vs aislamiento

Cada patrón se posiciona distinto: más aislamiento suele implicar más operación.

Valores cualitativos para comparar patrones; no es un benchmark.

La pieza clave

PostgreSQL schemas

Un schema es un espacio de nombres dentro de una base de datos: permite repetir el nombre de tabla sin conflictos.

public schema por defecto, ideal para lo compartido
acme.cursos y gea.cursos son tablas distintas
search_path indica en qué schemas buscar las tablas
✓
Con search_path = acme, public el ORM consulta acme primero y cae a public para lo compartido.
AulaSaaS

Una base de datos, muchos espacios

PostgreSQL · base de datos aulasass
public
  • client
  • domain
  • auth_user
  • planes
compartido
acme
  • cursos
  • alumnos
  • notas
tenant
gea
  • cursos
  • alumnos
  • notas
tenant
i
Cada colegio tiene sus propias tablas cursos y alumnos, sin verse entre sí.
Si compartes schema

Row-Level Security

Si eliges shared schema, no confíes solo en el filtrado del ORM: refuérzalo en la base de datos.

rls.sql
ALTER TABLE cursos ENABLE ROW LEVEL SECURITY;

CREATE POLICY aislamiento ON cursos
  USING (tenant_id = current_setting('app.tenant_id')::int);
✓
Postgres aplica el filtro por ti: aunque una query olvide el WHERE, no devuelve datos de otro tenant.
El riesgo real

Fugas de datos entre tenants

×
Un WHERE tenant_id=... olvidado en shared schema.
×
Un search_path mal fijado que apunta al schema equivocado.
×
Un dominio que resuelve al tenant incorrecto.
×
Caché o archivos servidos sin segmentar por tenant.
!
Una fuga entre clientes de una plataforma B2B suele ser el peor incidente posible: legal, económico y reputacional.
La librería

Qué es django-tenants

El paquete de referencia para multitenancy con schemas de PostgreSQL en Django.

Activo

Fork mantenido por la comunidad. Compatible con Django 5 y psycopg 3.

Transparente

Casi no cambias tu código: el schema se fija por petición.

Postgres-only

Usa schemas, así que requiere PostgreSQL (no SQLite/MySQL).

!
En 2019 se usaba django-tenant-schemas, pero está sin mantenimiento. Hoy instala django-tenants.
Flujo de una petición

Cómo funciona

Peticiónacme.tuapp.com
→
Middlewarelee el host
→
Tenantbusca el dominio
→
search_pathacme, public
→
VistaORM en ese schema
i
El host de la petición determina el tenant. Cada dominio vive en la tabla del schema public.
Enrutamiento

Un dominio, un tenant

El subdominio decide qué schema se consulta, sin tocar las vistas.

Enrutamiento por subdominio hacia el schema del tenant acme.tuapp.com gea.tuapp.com App Django TenantMainMiddleware request.tenant PostgreSQL public acme gea search_path = acme, public
Puesta en marcha

Instalación

Django 5, PostgreSQL y el driver psycopg 3.

bash
$ pip install django django-tenants "psycopg[binary]" $ django-admin startproject aulasass . $ python manage.py startapp customers
i
Necesitas un servidor PostgreSQL accesible; django-tenants no soporta SQLite.
Configuración · 1

Base de datos

Cambia el ENGINE por el backend de django-tenants.

settings.py
DATABASES = {
    "default": {
        "ENGINE": "django_tenants.postgresql_backend",
        "NAME": "aulasass",
        "USER": "aulasass",
        "PASSWORD": "...",
        "HOST": "localhost",
        "PORT": "5432",
    }
}
✓
Este backend es el que dirige las consultas al schema activo según el search_path.
Configuración · 2

Middleware y hosts

middleware
MIDDLEWARE = [
    "django_tenants.middleware.main"
    ".TenantMainMiddleware",
    # ... el resto
]
hosts
ALLOWED_HOSTS = [
    "localhost", ".localhost",
    ".tuapp.com",
]
!
El middleware va primero en la lista. El punto inicial en .localhost habilita los subdominios en desarrollo.
Configuración · 3

Shared, tenant e instaladas

settings.py
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
]
i
SHARED_APPS van al schema public; TENANT_APPS se crean en cada schema de tenant.
Configuración · 4

Modelo de tenant y enrutador

settings.py
TENANT_MODEL = "customers.Client"
TENANT_DOMAIN_MODEL = "customers.Domain"

DATABASE_ROUTERS = (
    "django_tenants.routers.TenantSyncRouter",
)
i
Le dicen a Django qué modelos representan al tenant y sus dominios, y qué apps sincronizar en cada schema.
Los modelos

Client y Domain

customers/models.py
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.
El dominio principal

El tenant público

Tu dominio principal también es un tenant, respaldado por el schema public.

shell
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,
)
!
Sin este tenant, tuapp.com responde 404: el middleware no encuentra a quién servir.
Migraciones

Migrar schemas

bash
$ python manage.py migrate_schemas --shared $ python manage.py migrate_schemas --tenant $ python manage.py migrate_schemas # ambos
i
No uses migrate a secas: migrate_schemas aplica cada app en el schema que le toca.
Flujo de migrate_schemas migrate_schemas --shared solo el schema public --tenant cada schema: acme · gea · kepler
A escala

Migraciones en paralelo

Con cientos de tenants, migrar uno a uno tarda demasiado.

bash
$ python manage.py migrate_schemas \ --executor=multiprocessing --parallel=8
!
Las migraciones deben ser idempotentes y rápidas: un error en un schema puede dejar tenants desincronizados.
Ilustrativo

Migrar cientos de tenants

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.

Alta de cliente

Crear un tenant

comando
python manage.py create_tenant \
  --schema_name=acme \
  --name=Acme \
  --domain=acme.localhost
programático
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.
En producción

Provisioning al registrarse

Crear un schema y migrarlo tarda; no lo hagas en la petición de registro.

tareas.py
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...
!
Marca el tenant como pendiente hasta que el schema esté listo: el middleware debe ignorar tenants sin terminar.
En la vista

Acceder al tenant actual

vistas.py
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.
Qué va dónde

Datos compartidos vs por tenant

public · shared
Compartido

Tenants, dominios, usuarios globales, planes, facturación, catálogos.

schema del tenant
Aislado

Cursos, alumnos, notas, asistencia, configuración y branding del colegio.

i
Regla: si dos clientes podrían discrepar sobre un dato, va por tenant. Si es común a todos, va en public.
Para no tropezar

Errores comunes del setup

×
Usar migrate en vez de migrate_schemas.
×
Olvidar TenantMainMiddleware o ponerlo fuera de orden.
×
No crear el tenant public y recibir 404 en el dominio raíz.
×
Poner apps con datos de cliente dentro de SHARED_APPS.
✓
Con el setup correcto, casi todo el código Django que ya sabes sigue funcionando igual.
Probar

Tests en multitenant

django-tenants aporta clases de test que crean un tenant temporal.

cursos/tests.py
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)
!
Los tests con schemas usan TransactionTestCase: son más lentos y no se paralelizan igual que los normales.
Personalización

Configuración por tenant

Cada colegio quiere su logo, su subdominio y sus funciones.

public/models.py
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)
✓
Configuración global del tenant → en public. Datos de negocio → en el schema del tenant.
Archivos

Media y archivos por tenant

Las subidas deben aislarse igual que los datos: una ruta por tenant.

storage.py
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)
!
Con S3, sirve los archivos con URLs firmadas y prefijo por tenant; nunca uses un bucket plano y público.
Tareas en background

Celery por tenant

Un worker no tiene subdominio: hay que decirle explícitamente en qué schema trabajar.

tareas.py
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)
✓
Siempre pasa el schema_name a la tarea y envuélvela en schema_context: sin eso, usa public.
Aislamiento

Seguridad y fugas

Admin limita ver tenants ajeno (TenantAdminMixin)
search_path fíjalo tú; nunca lo tomes del cliente
Cache incluye el schema en la clave
Sesiones de un tenant no valen en otro
Reset de contraseña con dominio correcto
Auditoría registra el tenant en cada acción
×
Nunca construyas el search_path concatenando texto del usuario: es una inyección SQL.
Escala

Rendimiento y conexiones

Un schema por tenant no multiplica conexiones, pero sí el trabajo de bootstrap.

CONN_MAX_AGE

Reutiliza conexiones; evita reconectar en cada petición.

PgBouncer

Pool del lado de la base de datos para muchos workers.

Índices por schema

Cada tabla replicada necesita sus índices; cuida el tamaño.

i
Vigila el número de schemas: miles de tablas replicadas cambian el comportamiento del planificador de Postgres.
Infraestructura

Despliegue

Una sola app atiende a todos los tenants; los recursos se comparten o se separan donde importa.

Despliegue de AulaSaaS Internet nginx balanceador worker 1 worker 2 worker 3 PostgreSQL public · compartido schemas por tenant Redis caché clave por tenant S3 media prefijo por tenant
Ver qué pasa

Observabilidad por tenant

i
Logs con el schema_name en cada línea.
i
Métricas y errores segmentados por tenant.
i
APM/tracing que propague el tenant en el contexto.
i
Alertas por tenant ruidoso o con errores.
✓
Sin el tenant en los logs, depurar un incidente en producción se vuelve adivinanza.
Negocio

Billing y metering por tenant

Si cobras por uso, necesitas medirlo por tenant y limitarlo.

Planes

Límites por plan: nº de alumnos, almacenamiento, funciones.

Metering

Registra consumo por tenant para facturar y avisar.

Suspensión

Un tenant impago se bloquea sin afectar al resto.

i
El estado de suscripción vive en public y condiciona el acceso al schema del tenant.
Ciclo de vida

Onboarding y offboarding

Ciclo de vida del tenant 1 Registro tenant + dominio 2 Provisioning schema + migraciones 3 Activación plan + subdominio 4 Suspensión bloquea acceso 5 Baja export + borrado
Cumplimiento

Borrado y export de datos

Derecho al olvido y portabilidad, aplicados por tenant.

export
Portabilidad

Volcar todo el schema de un tenant a JSON/CSV bajo demanda.

delete
Olvido

Borrar el tenant y su schema: auto_drop_schema o comando explícito.

!
El borrado debe ser definitivo e incluir backups, caché y ficheros, no solo las tablas.
Continuidad

Backups por tenant

Global pg_dump de toda la base
Por schema pg_dump -n acme
Restore puntual recuperar un solo cliente
Verificación probar restores periódicamente
✓
Poder restaurar un tenant sin tocar a los demás es otra ventaja de separar por schema.
Antes de producción

Checklist

✓
Tenant public creado y sirviendo.
✓
Migraciones shared/tenant en el pipeline de release.
✓
Tenant en logs, métricas y auditoría.
✓
Provisioning asíncrono con estado pendiente.
✓
Tests con TenantTestCase.
✓
Backups y borrado por schema probados.
Más allá de Django

Multitenancy con SQLAlchemy

enfoque A
schema por sesión

Igual que django-tenants: search_path por petición. Reutiliza el mismo esquema SQL.

enfoque B
tenant_id + RLS

Cada tabla lleva tenant_id; Postgres lo refuerza con RLS.

i
La decisión no cambia: compartir por filas o separar por schema. El framework solo cambia dónde fijas el contexto.
Enfoque A

schema-per-tenant con SQLAlchemy

db.py
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'
    )
!
Valida siempre el schema contra una lista blanca antes de interpolarlo: aquí también hay riesgo de inyección.
Enfoque B

tenant_id + RLS con SQLAlchemy

modelos.py
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')
✓
En cada petición: SET app.tenant_id = ... y Postgres filtra aunque olvides el WHERE.
Elegir stack

Django vs FastAPI para SaaS

Django + django-tenantsFastAPI + SQLAlchemy
MultitenancyListo (schemas)Manual (contexto)
AdminIncluidoNo incluido
VelocidadBuenaMuy alta
EcosistemaEnormeModerno, asíncrono
Ideal paraSaaS con back-officeAPIs y servicios
Mi experiencia

Xprende: SaaS de e-learning

Plataforma multicliente de aprendizaje con gamificación, construida sobre estas ideas.

cliente
Marathon

Programa de formación con retos y puntos.

cliente
Expoflores

Sectores y equipos con seguimiento propio.

cliente
Johannes Kepler

Colegio con alumnos, cursos y gamificación.

✓
Una sola plataforma, varios clientes: mismo código, datos y experiencia separados por tenant.
Lecciones

Qué aprendí

i
El aislamiento es un requisito de negocio, no un detalle.
i
La operación (migraciones, backups, logs) pesa más que el setup.
i
Automatiza el ciclo de vida del tenant desde el día uno.
i
Decide temprano qué es compartido y qué es por tenant.
✓
schema-per-tenant da aislamiento fuerte con una operación manejable: el mejor balance para B2B.
Para llevar

Chuleta multitenant

ENGINE django_tenants.postgresql_backend
Middleware TenantMainMiddleware primero
Apps SHARED_APPS / TENANT_APPS
Modelo TENANT_MODEL + TENANT_DOMAIN_MODEL
Migrar migrate_schemas --shared|--tenant
Contexto schema_context(...)
Tests TenantTestCase
Datos public vs schema del tenant
Para seguir

Recursos

Docs django-tenants.readthedocs.io
Código github.com/joseamaya/multitenant
Postgres Schemas y Row-Level Security
Artículo Multi-Tenant Data Architecture (Microsoft)
✓
El repo joseamaya/multitenant es un ejemplo mínimo para arrancar tu proyecto.