Skip to content

Schema Backend

Schema-per-Tenant Backend Module

This module implements the Schema-per-Tenant isolation strategy where each tenant gets its own PostgreSQL schema within a shared database.

Isolation Strategy

Each tenant is isolated in a separate PostgreSQL schema within the same database. This provides strong logical isolation while sharing physical database resources.

Architecture
  • All tenants share a single PostgreSQL database
  • Each tenant gets a dedicated schema (separate namespace)
  • Shared infrastructure (connection pool, server resources)
  • Table structures identical across schemas (same migrations)
  • Data completely isolated via schema search_path mechanism
Schema Concept

A PostgreSQL schema is a named namespace within a database: - Multiple schemas can coexist in one database - Tables in different schemas can have identical names (e.g., public.users, tenant1.users) - Each schema has its own permissions, sequences, functions - SET search_path controls which schema queries access

Example:

-- Create tenant1 schema
CREATE SCHEMA tenant1;

-- Create table in tenant1 schema
CREATE TABLE tenant1.users (id INT, name TEXT);

-- Access via fully qualified name
SELECT * FROM tenant1.users;

-- Or set search_path to search that schema automatically
SET search_path TO tenant1;
SELECT * FROM users;  -- Queries tenant1.users

Isolation Mechanism

PostgreSQL enforces schema isolation via search_path: - Each connection has a search_path (list of schemas to search) - Queries search schemas in order (typically "tenant1, public") - Cross-tenant data access requires explicit schema qualification - Very hard to accidentally leak data across tenants

Example isolation:

# Tenant1 context
SET search_path TO tenant1, public;
SELECT * FROM users;  # Returns tenant1.users, not tenant2.users

# Tenant2 context
SET search_path TO tenant2, public;
SELECT * FROM users;  # Returns tenant2.users, isolated from tenant1

Lifecycle
  1. create() - Creates tenant schema, runs migrations
  2. activate() - Sets search_path to tenant's schema
  3. deactivate() - Restores previous schema search_path
  4. delete() - Drops schema and removes data
  5. migrate() - Runs migrations in tenant's schema
Performance
  • Faster than database-per-tenant (fewer connections)
  • Slower than shared schema (more schema overhead)
  • Good balance for medium number of tenants (dozens to hundreds)
  • Migrations run faster (one database, multiple schemas)
Resource Usage
  • One database connection pool (shared across tenants)
  • Lower memory footprint than database-per-tenant
  • Scales better with many tenants (< 1000s)
  • Shared infrastructure simpler to manage
Limitations
  • Cannot use different database engines per tenant
  • All tenants share connection pool capacity
  • Single database server failure affects all tenants
  • Shared database configuration (charset, collation, etc.)
Usage Example
from django_omnitenant.backends.schema_backend import SchemaTenantBackend
from myapp.models import Tenant
from django_omnitenant.tenant_context import TenantContext

# Create tenant
tenant = Tenant.objects.create(
    tenant_id='acme',
    config={'schema_name': 'tenant_acme'}
)

# Provision schema
backend = SchemaTenantBackend(tenant)
backend.create(run_migrations=True)

# Use tenant
with TenantContext.use_tenant(tenant):
    # search_path automatically set to tenant_acme
    User.objects.create(username='john')

# Cleanup
backend.delete(drop_schema=True)

SchemaTenantBackend

Bases: BaseTenantBackend

Schema-per-Tenant isolation backend.

Implements the schema-per-tenant isolation strategy where each tenant gets its own PostgreSQL schema within a shared database.

This backend uses PostgreSQL's schema mechanism to provide logical data isolation while sharing physical database resources. Each tenant's tables exist in a separate schema namespace.

Key Features
  • Shared database with separate schemas per tenant
  • Lower resource usage than database-per-tenant
  • Faster schema creation than database creation
  • Migrations run in shared database (faster)
  • PostgreSQL search_path controls tenant isolation
Schema vs Database

Unlike database-per-tenant: - One PostgreSQL database (all tenants) - Multiple schemas within the database - Lower memory footprint - Fewer connections needed - Simpler infrastructure

Compared to shared-schema: - Complete logical isolation (per-schema) - Row-level filters not needed - Better performance (less filtering) - More complex migrations

Configuration

Tenants can specify schema_name in config: - If provided: use tenant.config['schema_name'] - If not provided: use tenant.tenant_id as schema name

Example:

# Uses 'acme' as schema name
tenant = Tenant.objects.create(
    tenant_id='acme',
    config={}
)

# Uses 'tenant_acme' as schema name
tenant = Tenant.objects.create(
    tenant_id='acme',
    config={'schema_name': 'tenant_acme'}
)

Isolation Mechanism

PostgreSQL search_path controls which schema is queried: - connection.set_schema(schema_name) sets the search_path - Subsequent queries default to that schema - Prevents cross-tenant data access - Very efficient (database-native)

Attributes:

Name Type Description
tenant BaseTenant

The tenant instance

schema_name str

PostgreSQL schema name for this tenant

previous_schema str

Saved schema for deactivation

Source code in django_omnitenant/backends/schema_backend.py
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
class SchemaTenantBackend(BaseTenantBackend):
    """
    Schema-per-Tenant isolation backend.

    Implements the schema-per-tenant isolation strategy where each tenant
    gets its own PostgreSQL schema within a shared database.

    This backend uses PostgreSQL's schema mechanism to provide logical data
    isolation while sharing physical database resources. Each tenant's tables
    exist in a separate schema namespace.

    Key Features:
        - Shared database with separate schemas per tenant
        - Lower resource usage than database-per-tenant
        - Faster schema creation than database creation
        - Migrations run in shared database (faster)
        - PostgreSQL search_path controls tenant isolation

    Schema vs Database:
        Unlike database-per-tenant:
        - One PostgreSQL database (all tenants)
        - Multiple schemas within the database
        - Lower memory footprint
        - Fewer connections needed
        - Simpler infrastructure

        Compared to shared-schema:
        - Complete logical isolation (per-schema)
        - Row-level filters not needed
        - Better performance (less filtering)
        - More complex migrations

    Configuration:
        Tenants can specify schema_name in config:
        - If provided: use tenant.config['schema_name']
        - If not provided: use tenant.tenant_id as schema name

        Example:
        ```python
        # Uses 'acme' as schema name
        tenant = Tenant.objects.create(
            tenant_id='acme',
            config={}
        )

        # Uses 'tenant_acme' as schema name
        tenant = Tenant.objects.create(
            tenant_id='acme',
            config={'schema_name': 'tenant_acme'}
        )
        ```

    Isolation Mechanism:
        PostgreSQL search_path controls which schema is queried:
        - connection.set_schema(schema_name) sets the search_path
        - Subsequent queries default to that schema
        - Prevents cross-tenant data access
        - Very efficient (database-native)

    Attributes:
        tenant (BaseTenant): The tenant instance
        schema_name (str): PostgreSQL schema name for this tenant
        previous_schema (str): Saved schema for deactivation
    """

    def __init__(self, tenant: BaseTenant):
        """
        Initialize schema backend for a tenant.

        Args:
            tenant (BaseTenant): Tenant instance to manage

        Process:
            1. Call parent __init__ to store tenant reference
            2. Determine schema name from config or use tenant_id
            3. Store schema name for use in SQL statements

        Schema Name Determination:
            - First priority: tenant.config.get('schema_name')
            - Fallback: tenant.tenant_id
            - This allows flexibility in naming schemas

        Example:
            ```python
            # Schema name will be 'acme'
            tenant1 = Tenant(tenant_id='acme')
            backend1 = SchemaTenantBackend(tenant1)
            assert backend1.schema_name == 'acme'

            # Schema name will be 'tenant_acme'
            tenant2 = Tenant(
                tenant_id='acme',
                config={'schema_name': 'tenant_acme'}
            )
            backend2 = SchemaTenantBackend(tenant2)
            assert backend2.schema_name == 'tenant_acme'
            ```
        """
        super().__init__(tenant)
        # Determine schema name: explicit config takes precedence over tenant_id
        # This allows customizing schema name while keeping tenant_id consistent
        self.schema_name = tenant.config.get("schema_name") or tenant.tenant_id
    def __init__(self, tenant: BaseTenant):
        super().__init__(tenant)
        self.schema_name = tenant.config.get("schema_name") or tenant.tenant_id

    def bind(self):
        """
        Create the tenant's PostgreSQL schema if it doesn't exist.

        This method ensures the schema exists by executing CREATE SCHEMA IF NOT EXISTS.
        The IF NOT EXISTS clause makes this operation idempotent - it's safe to call
        multiple times without errors.

        Process:
            1. Get database cursor
            2. Execute CREATE SCHEMA IF NOT EXISTS statement
            3. Print confirmation for logging

        Schema Creation:
            The CREATE SCHEMA statement creates a new schema namespace in the database:
            - Schema is empty (no tables initially)
            - Can then create tables, functions, etc. in this schema
            - Multiple schemas can coexist in the same database

            Example SQL executed:
            ```sql
            CREATE SCHEMA IF NOT EXISTS "tenant_acme"
            ```

        SQL Injection Prevention:
            Schema name is quoted with double quotes:
            - Prevents SQL injection through schema name
            - Allows special characters in schema names
            - Example: "tenant-acme" (with dash) becomes valid identifier

        Idempotency:
            The IF NOT EXISTS clause means:
            - First call: creates the schema
            - Subsequent calls: do nothing (no error)
            - Safe to call multiple times in same process

        Lifecycle:
            bind() is called during:
            - create() - When provisioning new tenant
            - activate() - To ensure schema exists (lazy binding)

        Performance:
            Schema creation is very fast:
            - No table creation needed
            - Just metadata operation
            - Typically completes in milliseconds
            - No locking issues

        Error Handling:
            Errors can occur from:
            - Invalid schema name
            - Permission denied
            - Database connection issues

            Current implementation raises any exceptions.

        Example:
            ```python
            from django_omnitenant.backends.schema_backend import SchemaTenantBackend
            from myapp.models import Tenant

            tenant = Tenant.objects.get(tenant_id='acme')
            backend = SchemaTenantBackend(tenant)

            # Create the schema
            backend.bind()
            # Now 'tenant_acme' schema exists in the database
            ```

        See Also:
            - create(): High-level provisioning that calls bind()
            - activate(): Lazy binds if needed
            - delete(): Removes the schema
        """
        # Get database cursor for executing SQL
        with connection.cursor() as cursor:
            # Execute CREATE SCHEMA with IF NOT EXISTS for idempotency
            # Double quotes around schema name prevent SQL injection
            # and allow special characters in schema names
            cursor.execute(f'CREATE SCHEMA IF NOT EXISTS "{self.schema_name}"')

        # Log successful binding for visibility
        print(f"[SCHEMA BACKEND] Schema '{self.schema_name}' ensured.")

    def create(self, run_migrations=False, **kwargs):
        """
        Provision a new tenant schema.

        This method creates the tenant's PostgreSQL schema and optionally runs
        initial migrations to set up tables.

        Process:
            1. Call bind() to create the schema
            2. Call parent create() which:
               - Emits tenant_created signal
               - Runs migrations if run_migrations=True

        Args:
            run_migrations (bool): Whether to run migrations after creation.
                                  Default is False.
            **kwargs: Additional arguments passed to parent create()

        Workflow:
            1. bind() → CREATE SCHEMA IF NOT EXISTS
            2. signal: tenant_created
            3. migrate() → Run Django migrations (if run_migrations=True)

        Migrations:
            When run_migrations=True:
            - All pending migrations are applied to tenant schema
            - Tables are created in the tenant's schema namespace
            - Each tenant gets identical table structure

            Example:
            ```
            public schema:
            - django_migrations (shared)

            tenant_acme schema:
            - users
            - products
            - orders
            (same structure as tenant_globex)
            ```

        Configuration:
            Schema name comes from:
            1. tenant.config['schema_name'] (if provided)
            2. tenant.tenant_id (default)

        Error Handling:
            If migration fails:
            ```python
            try:
                backend.create(run_migrations=True)
            except Exception as e:
                logger.error(f"Failed to provision tenant: {e}")
                # Schema may exist but be incomplete
                # May need manual cleanup
            ```

        Comparison to database_backend:
            - Schema creation much faster than database creation
            - No separate credentials needed
            - Shares connection pool
            - Migrations run in single database context

        Examples:
            ```python
            tenant = Tenant.objects.create(
                tenant_id='acme',
                config={'schema_name': 'tenant_acme'}
            )

            backend = SchemaTenantBackend(tenant)

            # Just create schema (no migrations)
            backend.create(run_migrations=False)

            # Create schema and run migrations
            backend.create(run_migrations=True)
            ```

        Performance:
            - Schema creation is fast (milliseconds)
            - Migration time depends on schema complexity
            - Can create many tenants in parallel
            - Much faster than database-per-tenant approach

        See Also:
            - bind(): Create the schema
            - migrate(): Run database migrations
            - delete(): Remove the schema
        """
        # Step 1: Create the PostgreSQL schema
        self.bind()

        # Step 2: Call parent create() to:
        # - Emit tenant_created signal for listeners
        # - Run migrations if run_migrations=True
        super().create(run_migrations=run_migrations, **kwargs)

    def migrate(self, *args, **kwargs):
        """
        Run database migrations for the tenant's schema.

        This method applies Django migrations to the tenant's specific schema,
        creating/updating tables within that schema namespace.

        Process:
            1. Activate tenant context (sets search_path to tenant schema)
            2. Run Django migrate command with database='default'
            3. Call parent migrate() to emit tenant_migrated signal

        Args:
            *args: Positional arguments for migrate command
            **kwargs: Keyword arguments for migrate command
                     - app_label: Migrate specific app
                     - migration_name: Migrate to specific migration
                     - verbosity: Output verbosity level

        Database Context:
            Uses TenantContext.use_tenant() to:
            - Activate tenant context
            - Set search_path to tenant schema
            - Subsequent migrations target tenant schema
            - Exit context when done

            The search_path tells PostgreSQL which schema to search:
            ```sql
            SET search_path TO tenant_acme, public;
            -- Now migrations create tables in tenant_acme, not public
            ```

        Single Database:
            Unlike database-per-tenant, all migrations use database='default':
            - All tenants share the same database
            - Only search_path differs per tenant
            - Migrations must be compatible across tenants
            - Much faster than separate database migrations

        Signals:
            Emits: tenant_migrated(sender=Tenant, tenant=instance)
            Handlers can perform post-migration setup

        Examples:
            ```python
            backend = SchemaTenantBackend(tenant)

            # Run all pending migrations
            backend.migrate()

            # Migrate specific app
            backend.migrate('myapp')

            # With verbosity
            backend.migrate(verbosity=2)
            ```

        Management Command:
            Typically invoked via Django command:
            ```bash
            python manage.py migratetenant acme
            python manage.py migratetenants
            ```

        Performance:
            Much faster than database-per-tenant:
            - Single database context
            - No connection switching
            - Fewer locks
            - Parallel migration support

        Error Handling:
            If migration fails, exception is caught but re-raised:
            ```python
            try:
                backend.migrate()
            except Exception as e:
                logger.error(f"Migration failed for {self.schema_name}: {e}")
                # Schema may be in inconsistent state
            ```

        See Also:
            - create(): Provision and optionally migrate
            - activate(): Set search_path to tenant schema
            - TenantContext: Context manager for tenant activation
        """
        # Activate tenant context before running migrations
        # This sets search_path to the tenant's schema
        # Ensures migrations run in the tenant's schema, not shared schema
        with TenantContext.use_tenant(self.tenant):
            # Run Django migrate command
            # database='default' because all tenants share the same database
            # search_path is controlled by TenantContext
            call_command("migrate", *args, database="default", **kwargs)

        # Call parent migrate() to emit tenant_migrated signal
        # Allows listeners to perform post-migration setup
        super().migrate()

    def delete(self, drop_schema=True):
        """
        Delete the tenant's PostgreSQL schema.

        This method optionally drops the tenant's schema (and all data in it),
        then emits the tenant_deleted signal.

        Process:
            1. If drop_schema=True: Execute DROP SCHEMA CASCADE
            2. Call parent delete() to emit tenant_deleted signal

        Args:
            drop_schema (bool): Whether to actually drop the schema.
                               Default is True. Set to False for soft delete.

        Raises:
            Exception: If schema drop fails (active connections, permissions, etc.)

        Destructive Operation:
            When drop_schema=True, this is IRREVERSIBLE!
            - All tables in the schema are dropped
            - All data in those tables is deleted
            - Cannot be recovered without backups

        DROP SCHEMA CASCADE:
            The CASCADE clause means:
            - Drop the schema itself
            - Drop all objects in the schema (tables, functions, etc.)
            - Drop objects that depend on schema objects
            - Clean removal without dependency errors

            Without CASCADE, would fail if schema has dependent objects.

        Two-Step Deletion:
            The method supports soft and hard deletion:

            1. drop_schema=False (soft delete):
               - Schema remains in database
               - Data is preserved
               - Allows for recovery if deletion accidental
               - Only signal is emitted

            2. drop_schema=True (hard delete):
               - Schema is permanently removed
               - All tables and data deleted
               - Frees disk space
               - Cannot be recovered

        Active Connections:
            If another connection is using the schema, DROP may fail:
            ```
            PostgreSQL ERROR: schema "tenant_acme" is being accessed by other users
            ```

            Solutions:
            - Wait for connections to close
            - Disconnect all users from database
            - Force terminate active connections

        Error Handling:
            The method uses IF EXISTS to be more graceful:
            ```python
            try:
                backend.delete(drop_schema=True)
            except Exception as e:
                logger.error(f"Error dropping schema: {e}")
                # Schema may not have been dropped
                # May need manual intervention
            ```

        SQL Injection Prevention:
            Schema name is quoted:
            ```sql
            DROP SCHEMA IF EXISTS "tenant_acme" CASCADE
            ```
            - Prevents injection through schema name
            - Safely handles special characters

        Examples:
            ```python
            tenant = Tenant.objects.get(tenant_id='acme')
            backend = SchemaTenantBackend(tenant)

            # Soft delete (keep data, remove from Django)
            backend.delete(drop_schema=False)
            # Schema remains, can be manually restored

            # Later, hard delete
            backend.delete(drop_schema=True)
            # Schema permanently deleted

            # Clean up model
            tenant.delete()
            ```

        Safe Deletion Workflow:
            ```python
            # Archive if needed for compliance
            archive_tenant_data(tenant)

            # Soft delete first
            backend.delete(drop_schema=False)

            # Verify everything is OK
            # Later, hard delete
            backend.delete(drop_schema=True)

            # Clean up
            tenant.delete()
            ```

        Comparison to database_backend:
            - Schema drop is much faster than database drop
            - No connection termination needed (same database)
            - Simpler cleanup process
            - Less disk space freed (only schema tables)

        See Also:
            - create(): Provision the schema
            - bind(): Create empty schema
            - tenant_deleted signal: For cleanup handlers
        """
        # Only drop the schema if explicitly requested
        if drop_schema:
            # Get database cursor for executing SQL
            with connection.cursor() as cursor:
                # Execute DROP SCHEMA with CASCADE and IF EXISTS
                # CASCADE drops schema and all objects in it
                # IF EXISTS prevents error if schema doesn't exist
                # Double quotes prevent SQL injection
                cursor.execute(f'DROP SCHEMA IF EXISTS "{self.schema_name}" CASCADE')

            # Log successful deletion
            print(f"[SCHEMA BACKEND] Schema '{self.schema_name}' dropped.")

        # Call parent delete() to emit tenant_deleted signal
        # Allows listeners to perform cleanup tasks
        super().delete()

    def activate(self):
        """
        Activate the tenant's schema for the current context.

        This method makes the tenant's schema the active search path, so subsequent
        database queries default to accessing that schema.

        Process:
            1. Ensure schema exists by calling bind()
            2. Get current PostgreSQL schema name
            3. Set search_path to tenant's schema
            4. Call parent activate() to emit tenant_activated signal

        Schema Activation:
            After activation, queries default to the tenant's schema:

            ```python
            backend.activate()
            User.objects.all()  # Queries tenant schema
            ```

            Executed SQL:
            ```sql
            SET search_path TO tenant_acme, public;
            SELECT * FROM users;  -- Queries tenant_acme.users, not public.users
            ```

        Lazy Binding:
            Calls bind() to ensure schema exists before activation:
            - If schema doesn't exist yet, it's created
            - Allows activate() to work even if create() wasn't called
            - Makes activation more resilient

        Schema Search Path:
            PostgreSQL search_path determines which schemas to search:
            - connection.set_schema(schema_name) sets the path
            - Typically: SET search_path TO tenant_acme, public
            - Queries check tenant schema first, then public
            - Prevents accidental access to other tenants

        Previous Schema:
            Saves current schema name before changing:
            - Allows restoration on deactivate()
            - Supports nested contexts
            - Ensures proper cleanup

        Lifecycle:
            Called when:
            - Entering TenantContext context manager
            - Request middleware starts processing
            - Explicitly switching tenant

        Examples:
            ```python
            from django_omnitenant.tenant_context import TenantContext

            # Automatic via context manager (preferred)
            with TenantContext.use_tenant(tenant):
                # activate() called automatically
                User.objects.all()  # Queries tenant schema
                # deactivate() called automatically

            # Manual usage
            backend.activate()
            try:
                User.objects.all()
            finally:
                backend.deactivate()
            ```

        Schema Isolation:
            By setting search_path to the tenant schema:
            - Other tenants' tables are inaccessible
            - Cross-tenant queries would fail (table not found)
            - Very strong isolation mechanism
            - Enforced at database level

        Performance:
            activate() is called for every request:
            - SET search_path is fast (microseconds)
            - No expensive operations
            - bind() uses CREATE SCHEMA IF NOT EXISTS (cached)
            - Very efficient activation

        Error Handling:
            If activate fails:
            - Exception is raised
            - Context is not fully activated
            - deactivate() won't be called
            - Caller must handle error

        Signals:
            Emits: tenant_activated (from parent class)
            Allows handlers to perform per-request setup

        See Also:
            - deactivate(): Exit tenant context
            - bind(): Ensure schema exists
            - TenantContext: Context manager for activation
        """
        # Ensure the schema exists before activation
        # Uses IF NOT EXISTS so idempotent (safe to call multiple times)
        self.bind()

        # Save the current PostgreSQL schema name
        # Allows restoration on deactivate() for proper cleanup
        # Important for nested contexts and exception handling
        self.previous_schema = get_active_schema_name(connection)

        # Set the PostgreSQL search_path to this tenant's schema
        # Makes queries default to this schema
        # Other tenants' tables become inaccessible
        connection.set_schema(self.schema_name)

        # Call parent activate() to emit tenant_activated signal
        # Handlers can perform per-request setup (logging, caching, etc.)
        super().activate()

    def deactivate(self):
        """
        Deactivate the tenant's schema and restore previous context.

        This method restores the PostgreSQL search_path to what it was before
        activation, effectively exiting the tenant's context.

        Process:
            1. Restore previous PostgreSQL schema search_path
            2. Call parent deactivate() to emit tenant_deactivated signal

        Schema Restoration:
            After deactivate, queries access previous schema:

            ```python
            backend.deactivate()
            User.objects.all()  # Queries previous schema, not tenant schema
            ```

            Executed SQL:
            ```sql
            SET search_path TO public;  -- Restore previous schema
            SELECT * FROM users;  -- Queries public.users
            ```

        Lifecycle:
            Called when:
            - Exiting TenantContext context manager
            - Request middleware finishes request
            - Explicitly exiting tenant context

        Exception Safety:
            deactivate() is guaranteed to be called even if errors occur,
            similar to try/finally semantics:

            ```python
            backend.activate()
            try:
                dangerous_operation()  # May raise exception
            finally:
                backend.deactivate()  # Always called, even on exception
            ```

        Nested Contexts:
            Supports nested tenant activations:

            ```python
            with TenantContext.use_tenant(tenant1):
                # Activates tenant1 schema
                with TenantContext.use_tenant(tenant2):
                    # Activates tenant2 schema
                    # Deactivates, back to tenant1
                # Deactivates, back to previous
            ```

            Each deactivate() restores the context from the previous level.

        Previous Schema Storage:
            The previous_schema is saved by activate():
            - deactivate() restores it
            - Supports any schema (public, another tenant, custom)
            - Handles all context scenarios

        Examples:
            ```python
            from django_omnitenant.tenant_context import TenantContext

            # Automatic via context manager (preferred)
            with TenantContext.use_tenant(tenant):
                # activate() called
                User.objects.all()
                # deactivate() called automatically

            # Manual usage
            backend.activate()
            try:
                User.objects.all()
            finally:
                backend.deactivate()  # Always called
            ```

        Performance:
            deactivate() is called for every request:
            - SET search_path is fast (microseconds)
            - No expensive operations
            - Minimal overhead

        Error Handling:
            If deactivate() itself fails:
            - Exception is raised but partial cleanup occurred
            - Previous schema restoration attempted
            - Caller should handle gracefully

            ```python
            try:
                backend.deactivate()
            except Exception as e:
                logger.error(f"Error deactivating: {e}")
                # Context is still partially cleaned up
            ```

        Signals:
            Emits: tenant_deactivated (from parent class)
            Allows handlers to perform cleanup
            - Clear logging context
            - Flush caches
            - Record metrics

        Thread Safety:
            TenantContext uses thread-local storage:
            - Each thread maintains independent context
            - deactivate() in one thread doesn't affect others
            - Safe for concurrent request processing

        See Also:
            - activate(): Enter tenant context
            - TenantContext: Context manager for activation/deactivation
            - Schema management: For consistent isolation
        """
        # Restore the PostgreSQL schema that was active before activate() was called
        # This ensures proper cleanup and context restoration
        connection.set_schema(self.previous_schema)

        # Call parent deactivate() to emit tenant_deactivated signal
        # Allows listeners to perform cleanup tasks
        super().deactivate()

bind()

Create the tenant's PostgreSQL schema if it doesn't exist.

This method ensures the schema exists by executing CREATE SCHEMA IF NOT EXISTS. The IF NOT EXISTS clause makes this operation idempotent - it's safe to call multiple times without errors.

Process
  1. Get database cursor
  2. Execute CREATE SCHEMA IF NOT EXISTS statement
  3. Print confirmation for logging
Schema Creation

The CREATE SCHEMA statement creates a new schema namespace in the database: - Schema is empty (no tables initially) - Can then create tables, functions, etc. in this schema - Multiple schemas can coexist in the same database

Example SQL executed:

CREATE SCHEMA IF NOT EXISTS "tenant_acme"

SQL Injection Prevention

Schema name is quoted with double quotes: - Prevents SQL injection through schema name - Allows special characters in schema names - Example: "tenant-acme" (with dash) becomes valid identifier

Idempotency

The IF NOT EXISTS clause means: - First call: creates the schema - Subsequent calls: do nothing (no error) - Safe to call multiple times in same process

Lifecycle

bind() is called during: - create() - When provisioning new tenant - activate() - To ensure schema exists (lazy binding)

Performance

Schema creation is very fast: - No table creation needed - Just metadata operation - Typically completes in milliseconds - No locking issues

Error Handling

Errors can occur from: - Invalid schema name - Permission denied - Database connection issues

Current implementation raises any exceptions.

Example
from django_omnitenant.backends.schema_backend import SchemaTenantBackend
from myapp.models import Tenant

tenant = Tenant.objects.get(tenant_id='acme')
backend = SchemaTenantBackend(tenant)

# Create the schema
backend.bind()
# Now 'tenant_acme' schema exists in the database
See Also
  • create(): High-level provisioning that calls bind()
  • activate(): Lazy binds if needed
  • delete(): Removes the schema
Source code in django_omnitenant/backends/schema_backend.py
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
def bind(self):
    """
    Create the tenant's PostgreSQL schema if it doesn't exist.

    This method ensures the schema exists by executing CREATE SCHEMA IF NOT EXISTS.
    The IF NOT EXISTS clause makes this operation idempotent - it's safe to call
    multiple times without errors.

    Process:
        1. Get database cursor
        2. Execute CREATE SCHEMA IF NOT EXISTS statement
        3. Print confirmation for logging

    Schema Creation:
        The CREATE SCHEMA statement creates a new schema namespace in the database:
        - Schema is empty (no tables initially)
        - Can then create tables, functions, etc. in this schema
        - Multiple schemas can coexist in the same database

        Example SQL executed:
        ```sql
        CREATE SCHEMA IF NOT EXISTS "tenant_acme"
        ```

    SQL Injection Prevention:
        Schema name is quoted with double quotes:
        - Prevents SQL injection through schema name
        - Allows special characters in schema names
        - Example: "tenant-acme" (with dash) becomes valid identifier

    Idempotency:
        The IF NOT EXISTS clause means:
        - First call: creates the schema
        - Subsequent calls: do nothing (no error)
        - Safe to call multiple times in same process

    Lifecycle:
        bind() is called during:
        - create() - When provisioning new tenant
        - activate() - To ensure schema exists (lazy binding)

    Performance:
        Schema creation is very fast:
        - No table creation needed
        - Just metadata operation
        - Typically completes in milliseconds
        - No locking issues

    Error Handling:
        Errors can occur from:
        - Invalid schema name
        - Permission denied
        - Database connection issues

        Current implementation raises any exceptions.

    Example:
        ```python
        from django_omnitenant.backends.schema_backend import SchemaTenantBackend
        from myapp.models import Tenant

        tenant = Tenant.objects.get(tenant_id='acme')
        backend = SchemaTenantBackend(tenant)

        # Create the schema
        backend.bind()
        # Now 'tenant_acme' schema exists in the database
        ```

    See Also:
        - create(): High-level provisioning that calls bind()
        - activate(): Lazy binds if needed
        - delete(): Removes the schema
    """
    # Get database cursor for executing SQL
    with connection.cursor() as cursor:
        # Execute CREATE SCHEMA with IF NOT EXISTS for idempotency
        # Double quotes around schema name prevent SQL injection
        # and allow special characters in schema names
        cursor.execute(f'CREATE SCHEMA IF NOT EXISTS "{self.schema_name}"')

    # Log successful binding for visibility
    print(f"[SCHEMA BACKEND] Schema '{self.schema_name}' ensured.")

create(run_migrations=False, **kwargs)

Provision a new tenant schema.

This method creates the tenant's PostgreSQL schema and optionally runs initial migrations to set up tables.

Process
  1. Call bind() to create the schema
  2. Call parent create() which:
  3. Emits tenant_created signal
  4. Runs migrations if run_migrations=True

Parameters:

Name Type Description Default

run_migrations

bool

Whether to run migrations after creation. Default is False.

False

**kwargs

Additional arguments passed to parent create()

{}
Workflow
  1. bind() → CREATE SCHEMA IF NOT EXISTS
  2. signal: tenant_created
  3. migrate() → Run Django migrations (if run_migrations=True)
Migrations

When run_migrations=True: - All pending migrations are applied to tenant schema - Tables are created in the tenant's schema namespace - Each tenant gets identical table structure

Example:

public schema:
- django_migrations (shared)

tenant_acme schema:
- users
- products
- orders
(same structure as tenant_globex)

Configuration

Schema name comes from: 1. tenant.config['schema_name'] (if provided) 2. tenant.tenant_id (default)

Error Handling

If migration fails:

try:
    backend.create(run_migrations=True)
except Exception as e:
    logger.error(f"Failed to provision tenant: {e}")
    # Schema may exist but be incomplete
    # May need manual cleanup

Comparison to database_backend
  • Schema creation much faster than database creation
  • No separate credentials needed
  • Shares connection pool
  • Migrations run in single database context

Examples:

tenant = Tenant.objects.create(
    tenant_id='acme',
    config={'schema_name': 'tenant_acme'}
)

backend = SchemaTenantBackend(tenant)

# Just create schema (no migrations)
backend.create(run_migrations=False)

# Create schema and run migrations
backend.create(run_migrations=True)
Performance
  • Schema creation is fast (milliseconds)
  • Migration time depends on schema complexity
  • Can create many tenants in parallel
  • Much faster than database-per-tenant approach
See Also
  • bind(): Create the schema
  • migrate(): Run database migrations
  • delete(): Remove the schema
Source code in django_omnitenant/backends/schema_backend.py
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
def create(self, run_migrations=False, **kwargs):
    """
    Provision a new tenant schema.

    This method creates the tenant's PostgreSQL schema and optionally runs
    initial migrations to set up tables.

    Process:
        1. Call bind() to create the schema
        2. Call parent create() which:
           - Emits tenant_created signal
           - Runs migrations if run_migrations=True

    Args:
        run_migrations (bool): Whether to run migrations after creation.
                              Default is False.
        **kwargs: Additional arguments passed to parent create()

    Workflow:
        1. bind() → CREATE SCHEMA IF NOT EXISTS
        2. signal: tenant_created
        3. migrate() → Run Django migrations (if run_migrations=True)

    Migrations:
        When run_migrations=True:
        - All pending migrations are applied to tenant schema
        - Tables are created in the tenant's schema namespace
        - Each tenant gets identical table structure

        Example:
        ```
        public schema:
        - django_migrations (shared)

        tenant_acme schema:
        - users
        - products
        - orders
        (same structure as tenant_globex)
        ```

    Configuration:
        Schema name comes from:
        1. tenant.config['schema_name'] (if provided)
        2. tenant.tenant_id (default)

    Error Handling:
        If migration fails:
        ```python
        try:
            backend.create(run_migrations=True)
        except Exception as e:
            logger.error(f"Failed to provision tenant: {e}")
            # Schema may exist but be incomplete
            # May need manual cleanup
        ```

    Comparison to database_backend:
        - Schema creation much faster than database creation
        - No separate credentials needed
        - Shares connection pool
        - Migrations run in single database context

    Examples:
        ```python
        tenant = Tenant.objects.create(
            tenant_id='acme',
            config={'schema_name': 'tenant_acme'}
        )

        backend = SchemaTenantBackend(tenant)

        # Just create schema (no migrations)
        backend.create(run_migrations=False)

        # Create schema and run migrations
        backend.create(run_migrations=True)
        ```

    Performance:
        - Schema creation is fast (milliseconds)
        - Migration time depends on schema complexity
        - Can create many tenants in parallel
        - Much faster than database-per-tenant approach

    See Also:
        - bind(): Create the schema
        - migrate(): Run database migrations
        - delete(): Remove the schema
    """
    # Step 1: Create the PostgreSQL schema
    self.bind()

    # Step 2: Call parent create() to:
    # - Emit tenant_created signal for listeners
    # - Run migrations if run_migrations=True
    super().create(run_migrations=run_migrations, **kwargs)

migrate(*args, **kwargs)

Run database migrations for the tenant's schema.

This method applies Django migrations to the tenant's specific schema, creating/updating tables within that schema namespace.

Process
  1. Activate tenant context (sets search_path to tenant schema)
  2. Run Django migrate command with database='default'
  3. Call parent migrate() to emit tenant_migrated signal

Parameters:

Name Type Description Default

*args

Positional arguments for migrate command

()

**kwargs

Keyword arguments for migrate command - app_label: Migrate specific app - migration_name: Migrate to specific migration - verbosity: Output verbosity level

{}
Database Context

Uses TenantContext.use_tenant() to: - Activate tenant context - Set search_path to tenant schema - Subsequent migrations target tenant schema - Exit context when done

The search_path tells PostgreSQL which schema to search:

SET search_path TO tenant_acme, public;
-- Now migrations create tables in tenant_acme, not public

Single Database

Unlike database-per-tenant, all migrations use database='default': - All tenants share the same database - Only search_path differs per tenant - Migrations must be compatible across tenants - Much faster than separate database migrations

Signals

Emits: tenant_migrated(sender=Tenant, tenant=instance) Handlers can perform post-migration setup

Examples:

backend = SchemaTenantBackend(tenant)

# Run all pending migrations
backend.migrate()

# Migrate specific app
backend.migrate('myapp')

# With verbosity
backend.migrate(verbosity=2)
Management Command

Typically invoked via Django command:

python manage.py migratetenant acme
python manage.py migratetenants

Performance

Much faster than database-per-tenant: - Single database context - No connection switching - Fewer locks - Parallel migration support

Error Handling

If migration fails, exception is caught but re-raised:

try:
    backend.migrate()
except Exception as e:
    logger.error(f"Migration failed for {self.schema_name}: {e}")
    # Schema may be in inconsistent state

See Also
  • create(): Provision and optionally migrate
  • activate(): Set search_path to tenant schema
  • TenantContext: Context manager for tenant activation
Source code in django_omnitenant/backends/schema_backend.py
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
def migrate(self, *args, **kwargs):
    """
    Run database migrations for the tenant's schema.

    This method applies Django migrations to the tenant's specific schema,
    creating/updating tables within that schema namespace.

    Process:
        1. Activate tenant context (sets search_path to tenant schema)
        2. Run Django migrate command with database='default'
        3. Call parent migrate() to emit tenant_migrated signal

    Args:
        *args: Positional arguments for migrate command
        **kwargs: Keyword arguments for migrate command
                 - app_label: Migrate specific app
                 - migration_name: Migrate to specific migration
                 - verbosity: Output verbosity level

    Database Context:
        Uses TenantContext.use_tenant() to:
        - Activate tenant context
        - Set search_path to tenant schema
        - Subsequent migrations target tenant schema
        - Exit context when done

        The search_path tells PostgreSQL which schema to search:
        ```sql
        SET search_path TO tenant_acme, public;
        -- Now migrations create tables in tenant_acme, not public
        ```

    Single Database:
        Unlike database-per-tenant, all migrations use database='default':
        - All tenants share the same database
        - Only search_path differs per tenant
        - Migrations must be compatible across tenants
        - Much faster than separate database migrations

    Signals:
        Emits: tenant_migrated(sender=Tenant, tenant=instance)
        Handlers can perform post-migration setup

    Examples:
        ```python
        backend = SchemaTenantBackend(tenant)

        # Run all pending migrations
        backend.migrate()

        # Migrate specific app
        backend.migrate('myapp')

        # With verbosity
        backend.migrate(verbosity=2)
        ```

    Management Command:
        Typically invoked via Django command:
        ```bash
        python manage.py migratetenant acme
        python manage.py migratetenants
        ```

    Performance:
        Much faster than database-per-tenant:
        - Single database context
        - No connection switching
        - Fewer locks
        - Parallel migration support

    Error Handling:
        If migration fails, exception is caught but re-raised:
        ```python
        try:
            backend.migrate()
        except Exception as e:
            logger.error(f"Migration failed for {self.schema_name}: {e}")
            # Schema may be in inconsistent state
        ```

    See Also:
        - create(): Provision and optionally migrate
        - activate(): Set search_path to tenant schema
        - TenantContext: Context manager for tenant activation
    """
    # Activate tenant context before running migrations
    # This sets search_path to the tenant's schema
    # Ensures migrations run in the tenant's schema, not shared schema
    with TenantContext.use_tenant(self.tenant):
        # Run Django migrate command
        # database='default' because all tenants share the same database
        # search_path is controlled by TenantContext
        call_command("migrate", *args, database="default", **kwargs)

    # Call parent migrate() to emit tenant_migrated signal
    # Allows listeners to perform post-migration setup
    super().migrate()

delete(drop_schema=True)

Delete the tenant's PostgreSQL schema.

This method optionally drops the tenant's schema (and all data in it), then emits the tenant_deleted signal.

Process
  1. If drop_schema=True: Execute DROP SCHEMA CASCADE
  2. Call parent delete() to emit tenant_deleted signal

Parameters:

Name Type Description Default

drop_schema

bool

Whether to actually drop the schema. Default is True. Set to False for soft delete.

True

Raises:

Type Description
Exception

If schema drop fails (active connections, permissions, etc.)

Destructive Operation

When drop_schema=True, this is IRREVERSIBLE! - All tables in the schema are dropped - All data in those tables is deleted - Cannot be recovered without backups

DROP SCHEMA CASCADE

The CASCADE clause means: - Drop the schema itself - Drop all objects in the schema (tables, functions, etc.) - Drop objects that depend on schema objects - Clean removal without dependency errors

Without CASCADE, would fail if schema has dependent objects.

Two-Step Deletion

The method supports soft and hard deletion:

  1. drop_schema=False (soft delete):
  2. Schema remains in database
  3. Data is preserved
  4. Allows for recovery if deletion accidental
  5. Only signal is emitted

  6. drop_schema=True (hard delete):

  7. Schema is permanently removed
  8. All tables and data deleted
  9. Frees disk space
  10. Cannot be recovered
Active Connections

If another connection is using the schema, DROP may fail:

PostgreSQL ERROR: schema "tenant_acme" is being accessed by other users

Solutions: - Wait for connections to close - Disconnect all users from database - Force terminate active connections

Error Handling

The method uses IF EXISTS to be more graceful:

try:
    backend.delete(drop_schema=True)
except Exception as e:
    logger.error(f"Error dropping schema: {e}")
    # Schema may not have been dropped
    # May need manual intervention

SQL Injection Prevention

Schema name is quoted:

DROP SCHEMA IF EXISTS "tenant_acme" CASCADE
- Prevents injection through schema name - Safely handles special characters

Examples:

tenant = Tenant.objects.get(tenant_id='acme')
backend = SchemaTenantBackend(tenant)

# Soft delete (keep data, remove from Django)
backend.delete(drop_schema=False)
# Schema remains, can be manually restored

# Later, hard delete
backend.delete(drop_schema=True)
# Schema permanently deleted

# Clean up model
tenant.delete()
Safe Deletion Workflow
# Archive if needed for compliance
archive_tenant_data(tenant)

# Soft delete first
backend.delete(drop_schema=False)

# Verify everything is OK
# Later, hard delete
backend.delete(drop_schema=True)

# Clean up
tenant.delete()
Comparison to database_backend
  • Schema drop is much faster than database drop
  • No connection termination needed (same database)
  • Simpler cleanup process
  • Less disk space freed (only schema tables)
See Also
  • create(): Provision the schema
  • bind(): Create empty schema
  • tenant_deleted signal: For cleanup handlers
Source code in django_omnitenant/backends/schema_backend.py
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
def delete(self, drop_schema=True):
    """
    Delete the tenant's PostgreSQL schema.

    This method optionally drops the tenant's schema (and all data in it),
    then emits the tenant_deleted signal.

    Process:
        1. If drop_schema=True: Execute DROP SCHEMA CASCADE
        2. Call parent delete() to emit tenant_deleted signal

    Args:
        drop_schema (bool): Whether to actually drop the schema.
                           Default is True. Set to False for soft delete.

    Raises:
        Exception: If schema drop fails (active connections, permissions, etc.)

    Destructive Operation:
        When drop_schema=True, this is IRREVERSIBLE!
        - All tables in the schema are dropped
        - All data in those tables is deleted
        - Cannot be recovered without backups

    DROP SCHEMA CASCADE:
        The CASCADE clause means:
        - Drop the schema itself
        - Drop all objects in the schema (tables, functions, etc.)
        - Drop objects that depend on schema objects
        - Clean removal without dependency errors

        Without CASCADE, would fail if schema has dependent objects.

    Two-Step Deletion:
        The method supports soft and hard deletion:

        1. drop_schema=False (soft delete):
           - Schema remains in database
           - Data is preserved
           - Allows for recovery if deletion accidental
           - Only signal is emitted

        2. drop_schema=True (hard delete):
           - Schema is permanently removed
           - All tables and data deleted
           - Frees disk space
           - Cannot be recovered

    Active Connections:
        If another connection is using the schema, DROP may fail:
        ```
        PostgreSQL ERROR: schema "tenant_acme" is being accessed by other users
        ```

        Solutions:
        - Wait for connections to close
        - Disconnect all users from database
        - Force terminate active connections

    Error Handling:
        The method uses IF EXISTS to be more graceful:
        ```python
        try:
            backend.delete(drop_schema=True)
        except Exception as e:
            logger.error(f"Error dropping schema: {e}")
            # Schema may not have been dropped
            # May need manual intervention
        ```

    SQL Injection Prevention:
        Schema name is quoted:
        ```sql
        DROP SCHEMA IF EXISTS "tenant_acme" CASCADE
        ```
        - Prevents injection through schema name
        - Safely handles special characters

    Examples:
        ```python
        tenant = Tenant.objects.get(tenant_id='acme')
        backend = SchemaTenantBackend(tenant)

        # Soft delete (keep data, remove from Django)
        backend.delete(drop_schema=False)
        # Schema remains, can be manually restored

        # Later, hard delete
        backend.delete(drop_schema=True)
        # Schema permanently deleted

        # Clean up model
        tenant.delete()
        ```

    Safe Deletion Workflow:
        ```python
        # Archive if needed for compliance
        archive_tenant_data(tenant)

        # Soft delete first
        backend.delete(drop_schema=False)

        # Verify everything is OK
        # Later, hard delete
        backend.delete(drop_schema=True)

        # Clean up
        tenant.delete()
        ```

    Comparison to database_backend:
        - Schema drop is much faster than database drop
        - No connection termination needed (same database)
        - Simpler cleanup process
        - Less disk space freed (only schema tables)

    See Also:
        - create(): Provision the schema
        - bind(): Create empty schema
        - tenant_deleted signal: For cleanup handlers
    """
    # Only drop the schema if explicitly requested
    if drop_schema:
        # Get database cursor for executing SQL
        with connection.cursor() as cursor:
            # Execute DROP SCHEMA with CASCADE and IF EXISTS
            # CASCADE drops schema and all objects in it
            # IF EXISTS prevents error if schema doesn't exist
            # Double quotes prevent SQL injection
            cursor.execute(f'DROP SCHEMA IF EXISTS "{self.schema_name}" CASCADE')

        # Log successful deletion
        print(f"[SCHEMA BACKEND] Schema '{self.schema_name}' dropped.")

    # Call parent delete() to emit tenant_deleted signal
    # Allows listeners to perform cleanup tasks
    super().delete()

activate()

Activate the tenant's schema for the current context.

This method makes the tenant's schema the active search path, so subsequent database queries default to accessing that schema.

Process
  1. Ensure schema exists by calling bind()
  2. Get current PostgreSQL schema name
  3. Set search_path to tenant's schema
  4. Call parent activate() to emit tenant_activated signal
Schema Activation

After activation, queries default to the tenant's schema:

backend.activate()
User.objects.all()  # Queries tenant schema

Executed SQL:

SET search_path TO tenant_acme, public;
SELECT * FROM users;  -- Queries tenant_acme.users, not public.users

Lazy Binding

Calls bind() to ensure schema exists before activation: - If schema doesn't exist yet, it's created - Allows activate() to work even if create() wasn't called - Makes activation more resilient

Schema Search Path

PostgreSQL search_path determines which schemas to search: - connection.set_schema(schema_name) sets the path - Typically: SET search_path TO tenant_acme, public - Queries check tenant schema first, then public - Prevents accidental access to other tenants

Previous Schema

Saves current schema name before changing: - Allows restoration on deactivate() - Supports nested contexts - Ensures proper cleanup

Lifecycle

Called when: - Entering TenantContext context manager - Request middleware starts processing - Explicitly switching tenant

Examples:

from django_omnitenant.tenant_context import TenantContext

# Automatic via context manager (preferred)
with TenantContext.use_tenant(tenant):
    # activate() called automatically
    User.objects.all()  # Queries tenant schema
    # deactivate() called automatically

# Manual usage
backend.activate()
try:
    User.objects.all()
finally:
    backend.deactivate()
Schema Isolation

By setting search_path to the tenant schema: - Other tenants' tables are inaccessible - Cross-tenant queries would fail (table not found) - Very strong isolation mechanism - Enforced at database level

Performance

activate() is called for every request: - SET search_path is fast (microseconds) - No expensive operations - bind() uses CREATE SCHEMA IF NOT EXISTS (cached) - Very efficient activation

Error Handling

If activate fails: - Exception is raised - Context is not fully activated - deactivate() won't be called - Caller must handle error

Signals

Emits: tenant_activated (from parent class) Allows handlers to perform per-request setup

See Also
  • deactivate(): Exit tenant context
  • bind(): Ensure schema exists
  • TenantContext: Context manager for activation
Source code in django_omnitenant/backends/schema_backend.py
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
def activate(self):
    """
    Activate the tenant's schema for the current context.

    This method makes the tenant's schema the active search path, so subsequent
    database queries default to accessing that schema.

    Process:
        1. Ensure schema exists by calling bind()
        2. Get current PostgreSQL schema name
        3. Set search_path to tenant's schema
        4. Call parent activate() to emit tenant_activated signal

    Schema Activation:
        After activation, queries default to the tenant's schema:

        ```python
        backend.activate()
        User.objects.all()  # Queries tenant schema
        ```

        Executed SQL:
        ```sql
        SET search_path TO tenant_acme, public;
        SELECT * FROM users;  -- Queries tenant_acme.users, not public.users
        ```

    Lazy Binding:
        Calls bind() to ensure schema exists before activation:
        - If schema doesn't exist yet, it's created
        - Allows activate() to work even if create() wasn't called
        - Makes activation more resilient

    Schema Search Path:
        PostgreSQL search_path determines which schemas to search:
        - connection.set_schema(schema_name) sets the path
        - Typically: SET search_path TO tenant_acme, public
        - Queries check tenant schema first, then public
        - Prevents accidental access to other tenants

    Previous Schema:
        Saves current schema name before changing:
        - Allows restoration on deactivate()
        - Supports nested contexts
        - Ensures proper cleanup

    Lifecycle:
        Called when:
        - Entering TenantContext context manager
        - Request middleware starts processing
        - Explicitly switching tenant

    Examples:
        ```python
        from django_omnitenant.tenant_context import TenantContext

        # Automatic via context manager (preferred)
        with TenantContext.use_tenant(tenant):
            # activate() called automatically
            User.objects.all()  # Queries tenant schema
            # deactivate() called automatically

        # Manual usage
        backend.activate()
        try:
            User.objects.all()
        finally:
            backend.deactivate()
        ```

    Schema Isolation:
        By setting search_path to the tenant schema:
        - Other tenants' tables are inaccessible
        - Cross-tenant queries would fail (table not found)
        - Very strong isolation mechanism
        - Enforced at database level

    Performance:
        activate() is called for every request:
        - SET search_path is fast (microseconds)
        - No expensive operations
        - bind() uses CREATE SCHEMA IF NOT EXISTS (cached)
        - Very efficient activation

    Error Handling:
        If activate fails:
        - Exception is raised
        - Context is not fully activated
        - deactivate() won't be called
        - Caller must handle error

    Signals:
        Emits: tenant_activated (from parent class)
        Allows handlers to perform per-request setup

    See Also:
        - deactivate(): Exit tenant context
        - bind(): Ensure schema exists
        - TenantContext: Context manager for activation
    """
    # Ensure the schema exists before activation
    # Uses IF NOT EXISTS so idempotent (safe to call multiple times)
    self.bind()

    # Save the current PostgreSQL schema name
    # Allows restoration on deactivate() for proper cleanup
    # Important for nested contexts and exception handling
    self.previous_schema = get_active_schema_name(connection)

    # Set the PostgreSQL search_path to this tenant's schema
    # Makes queries default to this schema
    # Other tenants' tables become inaccessible
    connection.set_schema(self.schema_name)

    # Call parent activate() to emit tenant_activated signal
    # Handlers can perform per-request setup (logging, caching, etc.)
    super().activate()

deactivate()

Deactivate the tenant's schema and restore previous context.

This method restores the PostgreSQL search_path to what it was before activation, effectively exiting the tenant's context.

Process
  1. Restore previous PostgreSQL schema search_path
  2. Call parent deactivate() to emit tenant_deactivated signal
Schema Restoration

After deactivate, queries access previous schema:

backend.deactivate()
User.objects.all()  # Queries previous schema, not tenant schema

Executed SQL:

SET search_path TO public;  -- Restore previous schema
SELECT * FROM users;  -- Queries public.users

Lifecycle

Called when: - Exiting TenantContext context manager - Request middleware finishes request - Explicitly exiting tenant context

Exception Safety

deactivate() is guaranteed to be called even if errors occur, similar to try/finally semantics:

backend.activate()
try:
    dangerous_operation()  # May raise exception
finally:
    backend.deactivate()  # Always called, even on exception
Nested Contexts

Supports nested tenant activations:

with TenantContext.use_tenant(tenant1):
    # Activates tenant1 schema
    with TenantContext.use_tenant(tenant2):
        # Activates tenant2 schema
        # Deactivates, back to tenant1
    # Deactivates, back to previous

Each deactivate() restores the context from the previous level.

Previous Schema Storage

The previous_schema is saved by activate(): - deactivate() restores it - Supports any schema (public, another tenant, custom) - Handles all context scenarios

Examples:

from django_omnitenant.tenant_context import TenantContext

# Automatic via context manager (preferred)
with TenantContext.use_tenant(tenant):
    # activate() called
    User.objects.all()
    # deactivate() called automatically

# Manual usage
backend.activate()
try:
    User.objects.all()
finally:
    backend.deactivate()  # Always called
Performance

deactivate() is called for every request: - SET search_path is fast (microseconds) - No expensive operations - Minimal overhead

Error Handling

If deactivate() itself fails: - Exception is raised but partial cleanup occurred - Previous schema restoration attempted - Caller should handle gracefully

try:
    backend.deactivate()
except Exception as e:
    logger.error(f"Error deactivating: {e}")
    # Context is still partially cleaned up
Signals

Emits: tenant_deactivated (from parent class) Allows handlers to perform cleanup - Clear logging context - Flush caches - Record metrics

Thread Safety

TenantContext uses thread-local storage: - Each thread maintains independent context - deactivate() in one thread doesn't affect others - Safe for concurrent request processing

See Also
  • activate(): Enter tenant context
  • TenantContext: Context manager for activation/deactivation
  • Schema management: For consistent isolation
Source code in django_omnitenant/backends/schema_backend.py
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
def deactivate(self):
    """
    Deactivate the tenant's schema and restore previous context.

    This method restores the PostgreSQL search_path to what it was before
    activation, effectively exiting the tenant's context.

    Process:
        1. Restore previous PostgreSQL schema search_path
        2. Call parent deactivate() to emit tenant_deactivated signal

    Schema Restoration:
        After deactivate, queries access previous schema:

        ```python
        backend.deactivate()
        User.objects.all()  # Queries previous schema, not tenant schema
        ```

        Executed SQL:
        ```sql
        SET search_path TO public;  -- Restore previous schema
        SELECT * FROM users;  -- Queries public.users
        ```

    Lifecycle:
        Called when:
        - Exiting TenantContext context manager
        - Request middleware finishes request
        - Explicitly exiting tenant context

    Exception Safety:
        deactivate() is guaranteed to be called even if errors occur,
        similar to try/finally semantics:

        ```python
        backend.activate()
        try:
            dangerous_operation()  # May raise exception
        finally:
            backend.deactivate()  # Always called, even on exception
        ```

    Nested Contexts:
        Supports nested tenant activations:

        ```python
        with TenantContext.use_tenant(tenant1):
            # Activates tenant1 schema
            with TenantContext.use_tenant(tenant2):
                # Activates tenant2 schema
                # Deactivates, back to tenant1
            # Deactivates, back to previous
        ```

        Each deactivate() restores the context from the previous level.

    Previous Schema Storage:
        The previous_schema is saved by activate():
        - deactivate() restores it
        - Supports any schema (public, another tenant, custom)
        - Handles all context scenarios

    Examples:
        ```python
        from django_omnitenant.tenant_context import TenantContext

        # Automatic via context manager (preferred)
        with TenantContext.use_tenant(tenant):
            # activate() called
            User.objects.all()
            # deactivate() called automatically

        # Manual usage
        backend.activate()
        try:
            User.objects.all()
        finally:
            backend.deactivate()  # Always called
        ```

    Performance:
        deactivate() is called for every request:
        - SET search_path is fast (microseconds)
        - No expensive operations
        - Minimal overhead

    Error Handling:
        If deactivate() itself fails:
        - Exception is raised but partial cleanup occurred
        - Previous schema restoration attempted
        - Caller should handle gracefully

        ```python
        try:
            backend.deactivate()
        except Exception as e:
            logger.error(f"Error deactivating: {e}")
            # Context is still partially cleaned up
        ```

    Signals:
        Emits: tenant_deactivated (from parent class)
        Allows handlers to perform cleanup
        - Clear logging context
        - Flush caches
        - Record metrics

    Thread Safety:
        TenantContext uses thread-local storage:
        - Each thread maintains independent context
        - deactivate() in one thread doesn't affect others
        - Safe for concurrent request processing

    See Also:
        - activate(): Enter tenant context
        - TenantContext: Context manager for activation/deactivation
        - Schema management: For consistent isolation
    """
    # Restore the PostgreSQL schema that was active before activate() was called
    # This ensures proper cleanup and context restoration
    connection.set_schema(self.previous_schema)

    # Call parent deactivate() to emit tenant_deactivated signal
    # Allows listeners to perform cleanup tasks
    super().deactivate()