Skip to main content

Cross-Schema Migrations

When your project has multiple PostgreSQL schemas (e.g. public and app), some changes cannot go inside your schema files — specifically anything that references objects in a different schema. This page explains the limitation, what to avoid, and how to do it correctly using manual migrations.


The Problem

PostKit uses pgschema to plan each schema separately. When pgschema analyses db/schema/app/, it creates an isolated temporary environment containing only the app schema objects. It has no knowledge of public.product, public.category, or any other schema.

This means if you write a cross-schema foreign key directly in your schema file:

-- db/schema/app/tables/order_item.sql
CREATE TABLE app.order_item (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL REFERENCES public.product(id) -- ❌ will fail
);

Running postkit db plan will fail with:

ERROR: relation "public.product" does not exist

The same applies to cross-schema views, functions, and triggers.


The Solution — Manual Migrations

Use postkit db migration to write the cross-schema SQL as a plain migration file. Manual migrations are applied directly by dbmate after all schemas have been created and populated, so every object is available.


Step-by-Step Example

Scenario

You have two schemas:

  • public — contains category and product tables
  • app — contains orders and order_item tables

order_item needs a FK to public.product, and you want a cross-schema view in app.


Step 1 — Keep schema files free of cross-schema references

Write each table without the cross-schema FK. Use a plain UUID column for now:

-- db/schema/public/tables/product.sql
CREATE TABLE public.product (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL,
price DOUBLE PRECISION NOT NULL CHECK (price >= 0)
);
-- db/schema/app/tables/order_item.sql
CREATE TABLE app.order_item (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
order_id UUID NOT NULL REFERENCES app.orders(id) ON DELETE CASCADE,
product_id UUID NOT NULL, -- ✅ plain column, no cross-schema FK here
quantity INTEGER NOT NULL DEFAULT 1 CHECK (quantity > 0),
unit_price DOUBLE PRECISION NOT NULL CHECK (unit_price >= 0)
);

Step 2 — Run the normal plan/apply flow

postkit db start
postkit db plan # plans public schema, then app schema — both succeed
postkit db apply # applies both schemas to local DB

Both schemas are now in the local database.


Step 3 — Create a manual migration for cross-schema objects

postkit db migration add_cross_schema_constraints

PostKit creates .postkit/db/session/<timestamp>_add_cross_schema_constraints.sql and opens it. Fill in the -- migrate:up section:

-- migrate:up

-- FK: app.order_item.product_id → public.product.id
-- Added as a manual migration because pgschema cannot plan cross-schema references.
ALTER TABLE app.order_item
ADD CONSTRAINT fk_order_item_product
FOREIGN KEY (product_id)
REFERENCES public.product(id)
ON DELETE RESTRICT;

-- Cross-schema view: app.order_summary joins app + public tables
CREATE VIEW app.order_summary AS
SELECT
oi.id,
oi.order_id,
p.name AS product_name,
p.price AS unit_price,
oi.quantity,
(p.price * oi.quantity) AS line_total
FROM app.order_item oi
JOIN public.product p ON p.id = oi.product_id;

-- migrate:down
DROP VIEW IF EXISTS app.order_summary;
ALTER TABLE app.order_item DROP CONSTRAINT IF EXISTS fk_order_item_product;

Step 4 — Apply, commit, and deploy

# Apply the manual migration (dbmate runs it; all schemas are present)
postkit db apply

# Verify locally, then commit
postkit db commit --message "add cross-schema FK and order summary view"

# Deploy to remote
postkit db deploy

What Belongs Where

ChangeWhere to put itManaged by
Tables, indexes, types, enums within one schemadb/schema/<name>/tables/ etc.pgschema → dbmate
RLS policies, grants within one schemadb/schema/<name>/policies/ etc.pgschema → dbmate
CREATE SCHEMA, CREATE ROLE, CREATE EXTENSIONdb/infra/infra step (psql)
Cross-schema FK constraintspostkit db migrationdbmate only
Cross-schema views, functions, triggerspostkit db migrationdbmate only

Adding Cross-Schema Objects Later (After Baseline)

If your schemas are already deployed and you want to add a new cross-schema constraint or view in a future change:

postkit db start

# If you changed any schema files, run plan + apply first:
postkit db plan
postkit db apply

# Then add the cross-schema SQL as a manual migration:
postkit db migration add_product_fk_to_order_item
# Edit the file, then:
postkit db apply
postkit db commit --message "add product FK to order_item"
postkit db deploy

You can mix pgschema-managed changes and manual migrations in the same session — postkit db apply handles both in sequence.


Common Errors

ERROR: relation "other_schema.table" does not exist during plan

A schema file contains a cross-schema reference. Remove the FK from the schema file and add it as a manual migration instead.

warn Schema "public" has no directory — skipping

The schema is listed in db.schemas in your config but db/schema/public/ doesn't exist on disk yet. Either run postkit db schema add public to scaffold it, or remove "public" from db.schemas if you only use a custom schema.