This page explains how to use the
client-add-organization-id SQL template
to backfill the organization_id column on existing application-schema
tables and assign clients to organizations.
WarningRehearse this on a staging copy before running it in production. This migration has failed in the field: the failure modes and the safeguards that prevent them are listed under Failure modes below.
Download the template:
client-add-organization-id-template.sql
It is client responsibility — CYPEX does not run it automatically. Each deployment customizes the template for its own schemas and tables.
The template:
- Adds
organization_id(BIGINT) columns to client application-schema tables. - Backfills existing rows to a configurable default organization.
- Replaces the migration literal
DEFAULTwithcypex.current_organization_id()so later inserts follow the session org. - Enables Row-Level Security (
ENABLE+FORCE). - Attaches a baseline policy with matching
USINGandWITH CHECK. - Creates a per-table index on
organization_idand runsANALYZE. - Fails loud on the first table error (set
continue_on_error := trueonly for inventory runs).
| |
Use a timestamped name in your CYPEX migrations directory that fits your
migration runner’s naming convention. If you already have the file from
the documentation site, copy it into migrations/ instead of curling.
The template is a DO $$ … $$ block. Edit these sections before running:
Default organization — resolved at the top of the block:
| |
Change the WHERE clause if existing rows should land on a different
organization.
Schema filter — which schemas the loop visits:
| |
Table filter — which tables inside each schema are migrated:
| |
Edit them to match your deployment. Do not run the unmodified template — it is a starting point, not a one-size-fits-all script.
Apply the customized template to a non-production replica and verify:
- Every expected table now has an
organization_idcolumn. - The RLS policy exists and matches the schema/table names.
- Row counts match before and after the migration.
- The backend integration tests pass.
Once staging has passed:
- Take a fresh backup.
- Schedule the maintenance window.
- Run the migration inside a transaction.
- Verify row counts.
- Cut traffic only after verification.
| Customization point | Purpose |
|---|---|
Default-organization SELECT | Which org existing rows receive via column DEFAULT. |
Schema nspname filter in the outer loop | Which application schemas are migrated. |
Table tablename filter in the inner loop | Which tables inside each schema are migrated. |
There is no separate dry-run flag: rehearse on a staging replica, then run the customized script in production.
The automated path in the template applies changes per table in this order:
- Add the
organization_idcolumn (NOT NULL DEFAULT <org>), or finish a partial prior run (backfill NULLs,SET NOT NULL, add FK if missing). - Replace the literal backfill
DEFAULTwithcypex.current_organization_id(). - Enable RLS (
ENABLE+FORCE). - Drop any existing policy (idempotency), then create the org policy with
matching
USINGandWITH CHECK. - Create the index on
organization_idandANALYZEthe table.
Because the column is added with a DEFAULT, existing rows are tagged
as the column is created — there is no separate backfill UPDATE in
Option 1 for brand-new columns. Re-runs that find an existing nullable
column do run an explicit UPDATE for NULL rows.
If you use Option 2 (manual / per-table) or write a custom backfill, prefer this safer order so RLS does not hide rows mid-migration:
- Add the
organization_idcolumn (nullable). - Backfill the column for existing rows (batched if large).
- Add the index (
CREATE INDEX CONCURRENTLYoutside a transaction). SET NOT NULL, add the FK, setDEFAULT cypex.current_organization_id().- Enable RLS and attach the policy (
USING+WITH CHECK). - Verify row counts.
The reason: if you enable RLS before the backfill, the backfill query itself may be subject to the policy and could miss rows.
If you need to move rows between organizations after the initial
assignment, see Enable RLS for the SET LOCAL pattern.
These are the failure modes seen in real runs:
- Cross-schema policy names. The template names policies
<schema>_<table>_user_accessso the same table name in two schemas does not collide in ops views. - Long-running migration. A multi-million-row table can exceed
statement timeouts when adding a
NOT NULL DEFAULTcolumn. Increase the timeout for the migration session and prefer Option 2 with batched updates (WHERE id BETWEEN ... AND ...) plusCREATE INDEX CONCURRENTLY. - Literal DEFAULT left behind. If you customize the script and skip
the
SET DEFAULT cypex.current_organization_id()step, later inserts that omitorganization_idsilently land in the migration org.
After running the template:
| |
Should be 0 for every migrated table. If any rows are still NULL,
either the migration did not complete or the template’s filter excluded
those rows.
- Prepare the existing database
- Enable RLS
- Verify and roll back
- Failure modes — read before production rollout.