Multi-Tenant API Design Patterns

If you're building a SaaS product, you're building a multi-tenant system. Whether you're serving 10 customers or 10,000, your API needs to keep their data separate, secure, and performant. Get it wrong, and you'll face data leaks, performance bottlenecks, and angry customers. Multi-tenancy isn't just about adding a tenant_

TRY NANO BANANA FOR FREE

Multi-Tenant API Design Patterns

TRY NANO BANANA FOR FREE
Contents

If you're building a SaaS product, you're building a multi-tenant system. Whether you're serving 10 customers or 10,000, your API needs to keep their data separate, secure, and performant. Get it wrong, and you'll face data leaks, performance bottlenecks, and angry customers.

Multi-tenancy isn't just about adding a tenant_id column to your database. It's about architectural decisions that affect routing, data isolation, security, performance, and scalability. This guide walks through practical strategies for building multi-tenant APIs, using examples from a hypothetical multi-tenant version of the PetStore API.

What is Multi-Tenancy?

Multi-tenancy means serving multiple customers (tenants) from a single application instance. Each tenant gets their own isolated environment, but they share the same codebase and infrastructure.

Think of it like an apartment building: multiple families (tenants) live in the same building (application), each with their own private space (data), but sharing common infrastructure (servers, database, code).

Why Multi-Tenancy Matters

Cost efficiency: One application serves many customers, reducing infrastructure and maintenance costs.

Faster updates: Deploy once, update everyone. No managing separate instances per customer.

Resource optimization: Share resources across tenants, improving utilization.

Easier scaling: Add new tenants without deploying new infrastructure.

The tradeoff? Complexity. You need robust isolation, security, and performance management.

Tenant Identification: Subdomain vs Path-Based Routing

The first decision: how do you identify which tenant is making a request?

Subdomain-Based Routing

Each tenant gets their own subdomain:

https://acme-pets.petstore.com/api/pets
https://fluffy-friends.petstore.com/api/pets
https://paws-and-claws.petstore.com/api/pets

Pros:- Clear tenant separation - Easy to implement SSL certificates per tenant - Feels more "branded" to customers - Simple to route at load balancer level

Cons:- Requires wildcard DNS setup - More complex SSL certificate management - Can't easily share sessions across tenants

Implementation:

from flask import Flask, request, g
import re

app = Flask(__name__)

@app.before_request
def identify_tenant():
    host = request.headers.get('Host', '')

    # Extract tenant from subdomain
    match = re.match(r'^([a-z0-9-]+)\.petstore\.com', host)

    if match:
        tenant_slug = match.group(1)
        tenant = Tenant.query.filter_by(slug=tenant_slug).first()

        if not tenant:
            return jsonify({'error': 'Tenant not found'}), 404

        g.tenant = tenant
    else:
        return jsonify({'error': 'Invalid host'}), 400

@app.route('/api/pets')
def list_pets():
    # g.tenant is automatically available
    pets = Pet.query.filter_by(tenant_id=g.tenant.id).all()
    return jsonify([pet.to_dict() for pet in pets])

Path-Based Routing

Tenant identifier in the URL path:

https://api.petstore.com/acme-pets/api/pets
https://api.petstore.com/fluffy-friends/api/pets
https://api.petstore.com/paws-and-claws/api/pets

Pros:- Simpler DNS setup - Single SSL certificate - Easier local development - Can share authentication across tenants

Cons:- Less "branded" feeling - Tenant slug visible in every URL - Slightly more complex routing logic

Implementation:

from flask import Flask, g

app = Flask(__name__)

@app.url_value_preprocessor
def extract_tenant(endpoint, values):
    if values:
        tenant_slug = values.pop('tenant_slug', None)
        if tenant_slug:
            tenant = Tenant.query.filter_by(slug=tenant_slug).first()
            if not tenant:
                abort(404, 'Tenant not found')
            g.tenant = tenant

@app.route('/<tenant_slug>/api/pets')
def list_pets(tenant_slug):
    pets = Pet.query.filter_by(tenant_id=g.tenant.id).all()
    return jsonify([pet.to_dict() for pet in pets])

Header-Based Routing

Pass tenant identifier in a custom header:

GET /api/pets
X-Tenant-ID: acme-pets

Pros:- Clean URLs - Easy to test - Flexible for different client types

Cons:- Not RESTful (URLs don't identify resources completely) - Harder to debug (can't just click a link) - Requires custom client configuration

Implementation:

@app.before_request
def identify_tenant():
    tenant_id = request.headers.get('X-Tenant-ID')

    if not tenant_id:
        return jsonify({'error': 'X-Tenant-ID header required'}), 400

    tenant = Tenant.query.filter_by(slug=tenant_id).first()
    if not tenant:
        return jsonify({'error': 'Tenant not found'}), 404

    g.tenant = tenant

Recommendation: Use subdomain routing for customer-facing APIs (better branding), path-based for internal APIs (simpler), and header-based for mobile apps (cleaner).

Data Isolation Strategies

Keeping tenant data separate is critical. Three main approaches:

1. Shared Database, Shared Schema

All tenants share the same database and tables. Each row has a tenant_id column.

CREATE TABLE pets (
    id SERIAL PRIMARY KEY,
    tenant_id INTEGER NOT NULL REFERENCES tenants(id),
    name VARCHAR(100),
    species VARCHAR(50),
    created_at TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_pets_tenant ON pets(tenant_id);

Pros:- Simple to implement - Easy to manage - Cost-effective - Easy to query across tenants (for analytics)

Cons:- Risk of data leakage if you forget WHERE tenant_id = ?- Performance can degrade with many tenants - Hard to customize per tenant - Compliance issues (some customers require physical separation)

Implementation with SQLAlchemy:

from flask_sqlalchemy import SQLAlchemy
from flask import g

db = SQLAlchemy()

class TenantMixin:
    """Automatically filter by tenant"""
    tenant_id = db.Column(db.Integer, db.ForeignKey('tenants.id'), nullable=False)

    @classmethod
    def query_for_tenant(cls):
        return cls.query.filter_by(tenant_id=g.tenant.id)

class Pet(db.Model, TenantMixin):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(100))
    species = db.Column(db.String(50))

# Usage
@app.route('/api/pets')
def list_pets():
    pets = Pet.query_for_tenant().all()  # Automatically filtered
    return jsonify([pet.to_dict() for pet in pets])

Add a safety check to prevent accidental cross-tenant queries:

from sqlalchemy import event
from sqlalchemy.orm import Session

@event.listens_for(Session, 'before_flush')
def check_tenant_isolation(session, flush_context, instances):
    """Ensure all objects belong to current tenant"""
    if not hasattr(g, 'tenant'):
        return

    for obj in session.new | session.dirty:
        if hasattr(obj, 'tenant_id'):
            if obj.tenant_id != g.tenant.id:
                raise ValueError(f'Attempted to modify object from different tenant')

2. Shared Database, Separate Schemas

Each tenant gets their own PostgreSQL schema:

-- Tenant 1
CREATE SCHEMA acme_pets;
CREATE TABLE acme_pets.pets (...);

-- Tenant 2
CREATE SCHEMA fluffy_friends;
CREATE TABLE fluffy_friends.pets (...);

Pros:- Better isolation than shared schema - Can customize schema per tenant - Easier to backup/restore individual tenants - Better performance (smaller tables)

Cons:- More complex migrations - Harder to query across tenants - Schema limit per database (PostgreSQL: ~1000 practical limit)

Implementation:

from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session, sessionmaker

class TenantDatabaseManager:
    def __init__(self, base_url):
        self.base_url = base_url
        self.sessions = {}

    def get_session(self, tenant_slug):
        if tenant_slug not in self.sessions:
            engine = create_engine(self.base_url)

            # Set search_path to tenant schema
            @event.listens_for(engine, 'connect')
            def set_search_path(dbapi_conn, connection_record):
                cursor = dbapi_conn.cursor()
                cursor.execute(f'SET search_path TO {tenant_slug}, public')
                cursor.close()

            session = scoped_session(sessionmaker(bind=engine))
            self.sessions[tenant_slug] = session

        return self.sessions[tenant_slug]

db_manager = TenantDatabaseManager('postgresql://localhost/petstore')

@app.before_request
def setup_tenant_db():
    g.db = db_manager.get_session(g.tenant.slug)

@app.route('/api/pets')
def list_pets():
    pets = g.db.query(Pet).all()  # Queries tenant-specific schema
    return jsonify([pet.to_dict() for pet in pets])

3. Separate Databases

Each tenant gets their own database:

acme_pets_db
fluffy_friends_db
paws_and_claws_db

Pros:- Complete isolation - Easy to scale (different servers) - Easy to backup/restore - Meets strict compliance requirements - Can customize everything per tenant

Cons:- Most complex to manage - Expensive (more database instances) - Migrations are harder - Cross-tenant queries nearly impossible

Implementation:

class TenantDatabaseManager:
    def __init__(self):
        self.engines = {}

    def get_engine(self, tenant):
        if tenant.id not in self.engines:
            # Each tenant has their own connection string
            engine = create_engine(tenant.database_url)
            self.engines[tenant.id] = engine

        return self.engines[tenant.id]

db_manager = TenantDatabaseManager()

@app.before_request
def setup_tenant_db():
    engine = db_manager.get_engine(g.tenant)
    g.db = scoped_session(sessionmaker(bind=engine))

@app.teardown_request
def cleanup_db(exception=None):
    if hasattr(g, 'db'):
        g.db.remove()

Recommendation: Start with shared database/shared schema for simplicity. Move to separate schemas or databases only when you need it (large tenants, compliance requirements, performance issues).

Tenant-Specific Rate Limiting

Different tenants have different plans and limits. Implement per-tenant rate limiting:

from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

limiter = Limiter(
    app,
    key_func=lambda: f"{g.tenant.id}:{get_remote_address()}",
    default_limits=[]
)

def get_tenant_rate_limit():
    """Return rate limit based on tenant's plan"""
    plan_limits = {
        'free': '100 per hour',
        'basic': '1000 per hour',
        'premium': '10000 per hour',
        'enterprise': '100000 per hour'
    }
    return plan_limits.get(g.tenant.plan, '100 per hour')

@app.route('/api/pets')
@limiter.limit(get_tenant_rate_limit)
def list_pets():
    pets = Pet.query_for_tenant().all()
    return jsonify([pet.to_dict() for pet in pets])

Add rate limit info to response headers:

@app.after_request
def add_rate_limit_headers(response):
    if hasattr(g, 'tenant'):
        # Get current usage from Redis
        key = f"rate_limit:{g.tenant.id}"
        current = redis_client.get(key) or 0
        limit = get_tenant_limit_number(g.tenant.plan)

        response.headers['X-RateLimit-Limit'] = str(limit)
        response.headers['X-RateLimit-Remaining'] = str(limit - int(current))

    return response

Tenant-Specific Configuration

Allow tenants to customize behavior:

class TenantConfig(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    tenant_id = db.Column(db.Integer, db.ForeignKey('tenants.id'))
    key = db.Column(db.String(100))
    value = db.Column(db.Text)

    @classmethod
    def get(cls, key, default=None):
        config = cls.query.filter_by(
            tenant_id=g.tenant.id,
            key=key
        ).first()
        return config.value if config else default

# Usage
@app.route('/api/pets')
def list_pets():
    # Check tenant-specific setting
    include_deleted = TenantConfig.get('include_deleted_pets', 'false') == 'true'

    query = Pet.query_for_tenant()
    if not include_deleted:
        query = query.filter_by(deleted=False)

    pets = query.all()
    return jsonify([pet.to_dict() for pet in pets])

Onboarding New Tenants

Automate tenant provisioning:

from flask import request, jsonify

@app.route('/api/admin/tenants', methods=['POST'])
def create_tenant():
    data = request.json

    # Validate tenant data
    if not data.get('slug') or not data.get('name'):
        return jsonify({'error': 'slug and name required'}), 400

    # Check if slug is available
    if Tenant.query.filter_by(slug=data['slug']).first():
        return jsonify({'error': 'Slug already taken'}), 409

    # Create tenant
    tenant = Tenant(
        slug=data['slug'],
        name=data['name'],
        plan=data.get('plan', 'free'),
        status='active'
    )
    db.session.add(tenant)
    db.session.commit()

    # Provision tenant resources
    provision_tenant(tenant)

    return jsonify(tenant.to_dict()), 201

def provision_tenant(tenant):
    """Set up everything a new tenant needs"""

    # 1. Create database schema (if using separate schemas)
    if USE_SEPARATE_SCHEMAS:
        db.session.execute(f'CREATE SCHEMA {tenant.slug}')
        db.session.commit()

        # Run migrations for tenant schema
        run_migrations_for_schema(tenant.slug)

    # 2. Create default data
    create_default_data(tenant)

    # 3. Set up default configuration
    create_default_config(tenant)

    # 4. Send welcome email
    send_welcome_email(tenant)

def create_default_data(tenant):
    """Create starter data for new tenant"""
    # Create admin user
    admin = User(
        tenant_id=tenant.id,
        email=f'admin@{tenant.slug}.com',
        role='admin'
    )
    db.session.add(admin)

    # Create sample pet
    sample_pet = Pet(
        tenant_id=tenant.id,
        name='Sample Pet',
        species='dog',
        breed='Golden Retriever'
    )
    db.session.add(sample_pet)

    db.session.commit()

Tenant Migrations

Handle schema changes across all tenants:

from alembic import op
import sqlalchemy as sa

def upgrade():
    """Add new column to pets table for all tenants"""

    if USE_SHARED_SCHEMA:
        # Simple: just add column
        op.add_column('pets', sa.Column('microchip_id', sa.String(50)))

    elif USE_SEPARATE_SCHEMAS:
        # Add column to each tenant schema
        tenants = Tenant.query.all()

        for tenant in tenants:
            op.execute(f'SET search_path TO {tenant.slug}')
            op.add_column('pets', sa.Column('microchip_id', sa.String(50)))

    elif USE_SEPARATE_DATABASES:
        # Run migration on each database
        tenants = Tenant.query.all()

        for tenant in tenants:
            engine = create_engine(tenant.database_url)
            with engine.connect() as conn:
                conn.execute('ALTER TABLE pets ADD COLUMN microchip_id VARCHAR(50)')

Monitoring and Observability

Track per-tenant metrics:

from prometheus_client import Counter, Histogram

request_count = Counter(
    'api_requests_total',
    'Total API requests',
    ['tenant', 'endpoint', 'method', 'status']
)

request_duration = Histogram(
    'api_request_duration_seconds',
    'API request duration',
    ['tenant', 'endpoint']
)

@app.before_request
def start_timer():
    g.start_time = time.time()

@app.after_request
def record_metrics(response):
    if hasattr(g, 'tenant') and hasattr(g, 'start_time'):
        duration = time.time() - g.start_time

        request_count.labels(
            tenant=g.tenant.slug,
            endpoint=request.endpoint,
            method=request.method,
            status=response.status_code
        ).inc()

        request_duration.labels(
            tenant=g.tenant.slug,
            endpoint=request.endpoint
        ).observe(duration)

    return response

Security Considerations

1. Always Validate Tenant Access

Never trust client-provided tenant IDs:

@app.route('/api/pets/<int:pet_id>')
def get_pet(pet_id):
    pet = Pet.query.get_or_404(pet_id)

    # CRITICAL: Verify pet belongs to current tenant
    if pet.tenant_id != g.tenant.id:
        abort(404)  # Don't reveal it exists

    return jsonify(pet.to_dict())

2. Prevent Tenant Enumeration

Don't leak information about other tenants:

@app.route('/<tenant_slug>/api/pets')
def list_pets(tenant_slug):
    tenant = Tenant.query.filter_by(slug=tenant_slug).first()

    if not tenant:
        # Don't say "tenant not found" - that confirms it doesn't exist
        abort(404)

    # Verify user has access to this tenant
    if not current_user.has_access_to_tenant(tenant):
        abort(404)  # Not 403, to avoid leaking existence

    g.tenant = tenant
    pets = Pet.query_for_tenant().all()
    return jsonify([pet.to_dict() for pet in pets])

3. Audit Cross-Tenant Access

Log any attempts to access other tenants' data:

@app.before_request
def audit_tenant_access():
    if hasattr(g, 'tenant') and current_user.is_authenticated:
        if current_user.tenant_id != g.tenant.id:
            log_security_event(
                event='cross_tenant_access_attempt',
                user_id=current_user.id,
                user_tenant=current_user.tenant_id,
                requested_tenant=g.tenant.id,
                endpoint=request.endpoint
            )
            abort(403)

Conclusion

Multi-tenant API design is about balancing isolation, performance, and complexity. Start simple with shared database/shared schema, and evolve your architecture as needs grow.

Key takeaways:

  • Choose routing strategy based on your use case (subdomain for branding, path for simplicity)
  • Start with shared schema, move to separate schemas/databases only when needed
  • Always validate tenant access—never trust client input
  • Implement per-tenant rate limiting and configuration
  • Automate tenant provisioning and migrations
  • Monitor per-tenant metrics for performance and billing

Build these patterns in from the start. Retrofitting multi-tenancy into an existing API is painful. But get it right early, and you'll have a solid foundation for scaling to thousands of tenants.