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
userstable. 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 TABLEfor anything they do not recognise. Payload's fifteen tables are not in your schema file. - Nobody can tell whose table is whose.
\dtreturns 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_URLat 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.