ICVOSS DJANGO PACKAGE REGISTRY

The package index django-boundary Provision and deprovision tenants

Provision and deprovision tenants

Documentation

Goal

Create and remove tenants from the command line using boundary_provision and boundary_deprovision, run provisioning and deprovisioning side effects through the BOUNDARY_POST_PROVISION_HOOK and BOUNDARY_PRE_DEPROVISION_HOOK hooks, and export a tenant's data to NDJSON before deleting it.

Prerequisites

Steps

1. Provision a tenant

boundary_provision calls TenantModel.objects.create(...) and prints the new tenant's primary key to stdout.

python manage.py boundary_provision --name "Club A" --slug "club-a"

The command requires --name and --slug. --region is optional and is only passed to create() when non-empty:

python manage.py boundary_provision --name "EU Club" --slug "eu-club" --region eu-west

Capture the printed PK for scripting:

TENANT_PK=$(python manage.py boundary_provision --name "Club A" --slug "club-a")

2. Set fields beyond name, slug, and region with --extra-fields

--extra-fields takes a JSON object. Its keys are merged into the create() kwargs, so they must match real field names on your tenant model. Use it for custom fields (for example plan, billing_email) or for the canonical fields when your custom model does not use name / slug / region.

python manage.py boundary_provision \
  --name "Pro Club" \
  --slug "pro-club" \
  --extra-fields '{"plan": "pro", "billing_email": "ops@proclub.example"}'

Invalid JSON raises a CommandError:

CommandError: Invalid JSON in --extra-fields: ...

--extra-fields defaults to "{}", so you can omit it.

3. Run a side effect after provisioning with BOUNDARY_POST_PROVISION_HOOK

Point BOUNDARY_POST_PROVISION_HOOK at a dotted path to a callable. After the tenant is created, the command imports it with import_string and calls it with the new tenant instance: hook(tenant). The hook runs before the PK is printed.

# settings.py
BOUNDARY_POST_PROVISION_HOOK = "tenants.hooks.post_provision"
# tenants/hooks.py
def post_provision(tenant):
    # Seed default rows, send a welcome email, kick off a Celery job, and so on.
    # Boundary does not activate tenant context for you here, so set it
    # yourself if you need scoped writes.
    from boundary.context import TenantContext

    with TenantContext.using(tenant):
        # ... create per-tenant defaults via scoped managers ...
        ...

The hook receives the saved tenant, so tenant.pk is populated. If the setting is unset (the default is None), no hook runs.

4. Preview a deprovision with --dry-run

boundary_deprovision resolves the tenant, then reports every tenant-scoped model and how many rows it would delete, without deleting anything. The tenant is matched by PK first, then by slug.

python manage.py boundary_deprovision --tenant club-a --dry-run

Output is prefixed with [DRY RUN]:

[DRY RUN] Would delete tenant: Club A
  Booking: 12 rows
  Invoice: 3 rows

An unresolvable identifier raises a CommandError ("Tenant not found: ...").

Note: the pre-deprovision hook (next step) still runs during a dry run, because it executes before the dry-run branch. Keep that hook side-effect-free, or guard its behaviour, if you rely on --dry-run being read-only.

5. Run a side effect before deletion with BOUNDARY_PRE_DEPROVISION_HOOK

BOUNDARY_PRE_DEPROVISION_HOOK mirrors the provision hook. It is imported and called as hook(tenant) before any rows are counted, exported, or deleted. Use it to revoke external resources, cancel subscriptions, or archive to cold storage.

# settings.py
BOUNDARY_PRE_DEPROVISION_HOOK = "tenants.hooks.pre_deprovision"
# tenants/hooks.py
def pre_deprovision(tenant):
    # Cancel billing, delete storage buckets, notify owners, and so on.
    ...

If unset (default None), no hook runs.

6. Export tenant data to NDJSON with --export

--export PATH streams every tenant-scoped row to a newline-delimited JSON file before deletion. Each line is one row, queried through the model's unscoped manager so strict mode does not block the read. Rows are streamed with .iterator(chunk_size=...); tune the chunk size with --batch-size (default 1000).

python manage.py boundary_deprovision \
  --tenant club-a \
  --export club-a-backup.ndjson \
  --batch-size 5000 \
  --yes

Each line has this shape: a _model key ("app_label.ModelName"), a _pk key (stringified primary key), and one entry per concrete field, keyed by the field's attname (so foreign keys appear as <field>_id). All values are stringified; None is preserved as JSON null.

{"_model": "bookings.Booking", "_pk": "42", "id": "42", "court": "1", "tenant_id": "7"}

Only models with at least one matching row produce lines, so an empty export file means the tenant had no scoped data. The file is written before the tenant and its rows are deleted.

7. Delete the tenant

Without --export, deprovision counts, confirms, then deletes. Each scoped model's rows are removed via model.unscoped.filter(tenant=tenant).delete(), then the tenant row itself is deleted. On success it prints Tenant <pk> deleted.

By default the command prompts interactively, listing affected models and asking you to type yes. Skip the prompt in scripts and CI with --yes:

python manage.py boundary_deprovision --tenant club-a --yes

Typical full lifecycle: export, then delete, non-interactively:

python manage.py boundary_deprovision \
  --tenant club-a \
  --export club-a-backup.ndjson \
  --yes

Verify it worked

Provision and confirm the PK and field values:

python manage.py shell
>>> from boundary.conf import get_tenant_model
>>> from django.core.management import call_command
>>> call_command("boundary_provision", name="Club A", slug="club-a", region="eu-west")
>>> Tenant = get_tenant_model()
>>> t = Tenant.objects.get(slug="club-a")
>>> t.region
'eu-west'

Dry-run a deprovision and confirm the tenant still exists:

python manage.py boundary_deprovision --tenant club-a --dry-run
python manage.py shell -c "from boundary.conf import get_tenant_model; print(get_tenant_model().objects.filter(slug='club-a').exists())"
# True

Export, then confirm the NDJSON line count matches the row count and each line parses:

python manage.py boundary_deprovision --tenant club-a --export out.ndjson --yes
wc -l out.ndjson
python -c "import json; [json.loads(l) for l in open('out.ndjson')]; print('ok')"

After a real deletion, confirm the tenant is gone:

python manage.py shell -c "from boundary.conf import get_tenant_model; print(get_tenant_model().objects.filter(slug='club-a').exists())"
# False

Common pitfalls