# 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.

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

```ts
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:

```ts
// 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

```ts
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:

```bash
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.**

```sql
-- 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:

```sql
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:

```sql
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

```sql
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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
