Skip to content

Letting Payload share your Postgres without wrecking your migrations

Two migration tools in one database will fight over table names and drop each other's tables. A separate schema keeps them apart for one config line.

Payload blog3 min readships at docs/solutions/blog-payload/sharing-postgres-with-your-app-tables.md

Tags: payload · postgres · migrations · drizzle · prisma · schema

You already have a database with your application's tables, and a migration tool that owns them. Now Payload wants the same database.

Point it straight at DATABASE_URL with no further thought and you get one of these, eventually:

  • A table-name collision. Payload creates a users table. So does your auth battery. Whichever runs second either fails or, worse, alters the first one.
  • Your ORM deletes Payload's tables. Most migration generators diff your schema definition against the live database and emit DROP TABLE for anything they do not recognise. Payload's fifteen tables are not in your schema file.
  • Nobody can tell whose table is whose. \dt returns forty tables from two systems with no naming convention between them.

The fix is one line

db: postgresAdapter({
  pool: { connectionString: process.env.DATABASE_URL },
  schemaName: "payload",
  migrationDir: path.resolve(dirname, "src/payload/migrations"),
  push: false,
}),

schemaName: "payload" puts every Payload table in its own Postgres schema. Your application tables stay in public. They are in the same database, on the same connection, in the same transaction if you want, but they cannot collide, because payload.users and public.users are different tables.

Postgres schemas are namespaces, not databases: there is no extra cost, no second connection, and a query can join across them.

Then tell your own migration tool to leave it alone. Most support a filter:

// drizzle.config.ts
export default defineConfig({
  schemaFilter: ["public"],
});

Prisma's introspection is limited to the schemas listed in the datasource (schemas = ["public"] with multiSchema), so it will not emit drops for tables it never sees.

Turn off push, in development too

push: false,

Payload's push mode diffs the config against the database at boot and alters the schema to match: the same idea as prisma db push. It is convenient for a prototype and unacceptable the moment two people share a database or one database is deployed:

  • there is no record of what changed;
  • a rename looks like a drop plus an add, so it deletes the column;
  • your teammate's laptop and yours end up with different schemas from the same commit.

With push: false, changing a collection means:

bun run payload:migrate:create posts_add_reading_time
bun run payload:migrate

and the SQL is a file in the repository that someone can read in review.

Read the generated SQL

Payload's generator is good, and it cannot read your mind. Two cases need hand-editing every time:

Renames become drop-and-add.

-- generated
ALTER TABLE "payload"."posts" DROP COLUMN "summary";
ALTER TABLE "payload"."posts" ADD COLUMN "excerpt" varchar;

-- what you meant
ALTER TABLE "payload"."posts" RENAME COLUMN "summary" TO "excerpt";

A new required field on a populated table needs the backfill in the same migration, before the constraint:

ALTER TABLE "payload"."posts" ADD COLUMN "excerpt" varchar;
UPDATE "payload"."posts" SET "excerpt" = left("title", 160) WHERE "excerpt" IS NULL;
ALTER TABLE "payload"."posts" ALTER COLUMN "excerpt" SET NOT NULL;

Joining across the two schemas

Once they share a database, you can genuinely join CMS content to application data: the thing a hosted CMS cannot do at all:

select p.title, count(v.id) as views
from payload.posts p
left join public.page_views v on v.path = '/blog/' || p.slug
where p._status = 'published'
group by p.title
order by views desc;

Do it read-only, from your own query layer. Do not INSERT or UPDATE Payload tables directly: hooks, versions, search indexes and relationship tables all expect writes to go through Payload, and a hand-written insert skips every one of them. Writes go through the local API.

Connection budget

Both systems now draw from the same pool. On serverless that is the constraint that bites first, because every function instance holds its own connections.

  • Point DATABASE_URL at your provider's pooled endpoint.
  • Keep the unpooled endpoint for migrations, which need session-level locks that transaction poolers drop.
  • Remember the admin panel is chatty: opening a document list is several queries. It is an internal tool with a handful of users, so this matters less than it sounds, but it is not free.

Verifying the separation

select table_schema, count(*)
from information_schema.tables
where table_schema in ('public', 'payload')
group by table_schema;

You should see two rows, and every Payload table under payload. Then run your own migration generator and read what it produces: if it contains a single DROP TABLE for anything in the payload schema, the filter is not applied and you have found the problem before it found you.