all posts

The best Prisma Postgres alternatives in 2026

Ajay Kumar··10 min read

Prisma Postgres is three products sold as one convenience: the Prisma ORM you already use, a connection pooling and caching layer, and a managed Postgres instance. That bundling is genuinely useful — it removes the connection-limit problem that makes Prisma awkward on serverless, and it means one dashboard instead of three.

It also means that when you want to change one part, you are shopping for a replacement for all three, which is why alternative lists in this space are confusing. So start by separating them, because in most cases you are keeping the ORM. I build PandaStack, which is a candidate for the database layer only.

You almost certainly do not have to leave the Prisma ORM to leave Prisma Postgres. Prisma has always pointed at any Postgres via a connection string; the managed database is a newer, separate offering. Everything below assumes your schema, migrations, and generated client stay exactly as they are.

The problem the bundle exists to solve

Postgres allocates a backend process per connection, so its connection limit is low by the standards of modern deployment — the practical ceiling is in the hundreds, and each idle connection still costs memory. Serverless breaks this assumption comprehensively: every warm function instance holds its own pool, so concurrency turns directly into connections and a traffic spike exhausts the database before it exhausts anything else.

There are two kinds of pooler and the difference matters enormously for an ORM. A session pooler assigns a client a dedicated backend for the whole session, which preserves every Postgres feature and does not help much with the count. A transaction pooler assigns a backend only for the duration of a transaction, which is what actually multiplexes hundreds of clients onto a handful of connections — at the cost of losing anything that spans transactions: session variables, `LISTEN`/`NOTIFY`, advisory locks held across statements, and prepared statements unless the pooler handles them explicitly.

This is the migration detail that bites Prisma users specifically. Prisma uses prepared statements by default, and a transaction pooler in the wrong mode will produce errors about statements that already exist or do not exist — intermittently, under concurrency, which is the worst way to find out. The fix is either a pooler that supports prepared statements properly or disabling them in the connection string. Verify it under load, not with one request.
# The two-URL pattern is what makes Prisma behave on any pooled Postgres.
# It matters because migrations and queries want different connections.

# DATABASE_URL -> the POOLER. Every runtime query goes here.
#   pgbouncer=true tells Prisma to stop using named prepared statements,
#   which is what breaks intermittently in transaction pooling mode.
DATABASE_URL="postgresql://user:pw@pooler.example.com:6543/app?pgbouncer=true&connection_limit=1"

# DIRECT_URL -> the DATABASE ITSELF, bypassing the pooler. prisma migrate
# needs advisory locks and DDL in a session, which transaction pooling
# cannot give it. Without this, migrations hang or fail confusingly.
DIRECT_URL="postgresql://user:pw@db.example.com:5432/app?sslmode=require"

# connection_limit=1 per instance is deliberate on serverless: you have
# many short-lived instances, so the pool belongs in the pooler, not in
# each copy of your app. On a long-running server, raise it.
// schema.prisma -- the datasource block that goes with the two URLs.
// directUrl is not optional if you run migrations against a pooled
// database. It is the single most common cause of "prisma migrate
// deploy just hangs in CI".
datasource db {
  provider  = "postgresql"
  url       = env("DATABASE_URL")   // pooled: runtime queries
  directUrl = env("DIRECT_URL")     // unpooled: migrations, introspection
}

generator client {
  provider = "prisma-client-js"
}

Replace one layer at a time

  • The ORM — keep it. Prisma's schema language, migration workflow, and generated client are the part with the highest switching cost and the least reason to switch. If you do want out, Drizzle is the usual destination and is a genuine rewrite of your data layer, not a config change.
  • The pooler — replaceable and often replaceable with nothing. If your app runs as a long-lived process rather than as functions, you may not need one at all: one process with a properly sized pool is exactly what Postgres was designed for.
  • The database — the most replaceable of the three, and the one where the differences between vendors are largest.

The database layer

  • Neon — Serverless Postgres with storage-level branching and scale-to-zero, plus a built-in pooler. The closest thing to a like-for-like swap for the convenience Prisma Postgres offers, and branching per pull request is genuinely excellent.
  • Supabase — Postgres with auth, storage, realtime, and a pooler attached. Choose it when you want more than a database; you can absolutely use only the Postgres part and ignore the rest.
  • Amazon RDS or Aurora — The conservative choice, and correct for a lot of production systems. Aurora Serverless v2 gives you scaling without the operational surface of self-managed Postgres, and RDS Proxy handles pooling. You own more configuration and get more control.
  • Google Cloud SQL and Azure Database for PostgreSQL — The same argument inside those clouds, with the same benefit of the network path staying private.
  • Crunchy Bridge — Managed Postgres by people who are unusually serious about Postgres, with fewer abstractions between you and the engine. Good when you want a real DBA-grade database rather than a product built on top of one.
  • Timescale — Postgres tuned for time-series, with continuous aggregates and compression that are hard to replicate by hand. The pick when a large share of your tables are append-only measurements.
  • DigitalOcean, Render, and Railway managed Postgres — Convenient and well-integrated when your app is already there. Check backup granularity and point-in-time recovery before treating one as your system of record.
  • Self-hosted Postgres — Still the cheapest and most controllable, and still the one that means someone owns backups, failover, upgrades, and being paged. Reasonable for one team with one database and an appetite for it.
  • PandaStack managed Postgres — Each database is a Firecracker microVM with a durable volume rather than a tenant in a shared cluster, which is an unusual trade in this list. You get a real Postgres 16 with root-level resource guarantees, point-in-time recovery, cross-host failover, and branching — including a warm branch that inherits the parent's hot cache, so a preview environment's first query is not a cold read. It auto-suspends when idle and bills storage only while asleep. The trade-off is honest: creation takes 30–90 seconds rather than being instant, because a VM boots and Postgres bootstraps, and it is not a globally distributed read layer.

The pooler layer

  • PgBouncer — The default answer, deployed by everyone, and the reference implementation of transaction pooling. Run it yourself or use the one your provider already runs.
  • Supavisor — Supabase's pooler, built for very high connection counts, and available whether or not you use the rest of Supabase.
  • PgCat — A newer pooler with load balancing and sharding features, worth a look if you need more than pooling.
  • RDS Proxy — The managed option inside AWS, with IAM integration and failover awareness.
  • Nothing at all — Genuinely on the table. If your app is a long-running process on a handful of instances, a correctly sized in-process pool is simpler and faster than adding a hop. Poolers exist because of serverless connection churn; if you do not have that, you may not have the problem.

Branching is the feature worth shopping for

If you have got used to a database branch per pull request, that is the capability you will miss most, and it is the one that differs most between providers. The important question is not whether a provider offers branching but what a branch actually is, because the word covers three quite different things.

  • A copy-on-write clone of the storage — fast to create, diverges as you write, and the honest meaning of the word. Neon's branches work this way; so do PandaStack's, at the volume level.
  • A restore from a backup into a new instance — correct data, minutes to create, and fine for a nightly staging refresh rather than a per-pull-request workflow.
  • A fresh empty database plus your migrations and seed data — not a branch at all, but frequently sufficient, and by far the most portable option. If your seed data is small, this works on literally every provider and costs nothing to build.

One detail that separates them in practice: a freshly created branch usually has a cold cache, so the first queries against it read from storage rather than memory. On a small dataset nobody notices. On a large one, a preview environment feels broken for its first minute in a way that has nothing to do with your code — which is why a branch that inherits the parent's warm buffer cache is a meaningfully different product from one that does not.

# Branch a running database, then point Prisma at the branch. The whole
# pattern for a per-pull-request environment is these three steps.

# 1. Branch. The parent is untouched; the branch gets its own credentials
#    and its own backup chain. Warm by default: it inherits the parent's
#    hot cache, so the first query is not a cold storage read.
BRANCH=$(curl -sS -X POST \
  https://api.pandastack.ai/v1/databases/$PARENT_DB_ID/branch \
  -H "Authorization: Bearer $PANDASTACK_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"label": "pr-482"}' | jq -r .id)

# 2. Wait for it, then read the connection string off the branch.
until [ "$(curl -sS -H "Authorization: Bearer $PANDASTACK_API_KEY" \
  https://api.pandastack.ai/v1/databases/$BRANCH | jq -r .status)" = "running" ]
do sleep 2; done

export DATABASE_URL=$(curl -sS -H "Authorization: Bearer $PANDASTACK_API_KEY" \
  https://api.pandastack.ai/v1/databases/$BRANCH | jq -r .connection_url)
export DIRECT_URL="$DATABASE_URL"     # no pooler in front of a branch

# 3. Bring the schema up to the PR's migrations and run the suite.
npx prisma migrate deploy
npm test

# Tear it down with the environment. A branch you forget about is a
# database you are paying for.
curl -sS -X DELETE -H "Authorization: Bearer $PANDASTACK_API_KEY" \
  https://api.pandastack.ai/v1/databases/$BRANCH

What to check before you move

  • Extensions. If you use pgvector, PostGIS, pg_cron, or anything else, confirm the target supports the exact extension and version. This is the single most common blocker and the cheapest to check first.
  • Prepared statements through the pooler. Test under concurrency, not with one request. Intermittent "prepared statement already exists" errors in production are the failure mode.
  • Migration path. `prisma migrate deploy` needs an unpooled connection. Make sure your CI has one and that it can reach the database from wherever CI runs.
  • Point-in-time recovery, and its window. "We take backups" and "you can restore to 14:32 last Tuesday" are very different products. Ask for the recovery window and the granularity.
  • Actually perform a restore. Before you cut over, restore a backup into a scratch database and diff row counts. An untested backup is a hypothesis.
  • Where the database physically is. If your app is in one region and the database is in another, every query pays that distance and no ORM setting will fix it.

The short version

Keep the Prisma ORM. Then decide whether you actually need a pooler — if your app is a long-running process, you may not — and pick the database on the axis that matters to you: Neon for branching and scale-to-zero, RDS or Cloud SQL for conservative production, Crunchy for Postgres seriousness, a VM-per-database platform like mine if isolation and guaranteed resources matter more than instant creation.

The two things that will actually go wrong are prepared statements through a transaction pooler and `prisma migrate` against a pooled URL. Both are configuration, both are documented, and both are much easier to fix on a Tuesday afternoon than during a cutover.

Frequently asked questions

Can I use the Prisma ORM with a database other than Prisma Postgres?

Yes — that is the ORM's original and primary mode. Prisma connects to any PostgreSQL over a connection string, and Prisma Postgres is a separate, newer managed-database offering rather than a requirement. Moving means changing DATABASE_URL, adding a directUrl if the new database sits behind a transaction pooler, and running prisma migrate deploy against the new instance. Your schema.prisma, your migration history, and your generated client are unchanged. Two things to verify rather than assume: that every Postgres extension you use exists on the target at the version you need, and that prepared statements behave correctly through whatever pooler is in front of it — that second one fails intermittently under concurrency rather than immediately, so test it with load.

Do I still need a connection pooler with Prisma?

It depends entirely on your deployment shape, and the answer is genuinely no for a lot of apps. Postgres allocates a process per connection, so its ceiling is in the hundreds — a problem only if you have many short-lived application instances each holding their own pool, which is exactly what serverless produces. If your app is a long-running server on a handful of instances, an in-process pool sized deliberately is simpler, faster, and one less hop than adding a pooler. If you are on functions or edge runtimes, you need one, and you want transaction mode to get real multiplexing. In that case set connection_limit=1 in the Prisma URL so the pooling happens in the pooler rather than being duplicated in every instance, and add pgbouncer=true so Prisma stops using named prepared statements.

Why does prisma migrate hang or fail against a pooled connection?

Because migrations need things transaction pooling cannot provide. Prisma Migrate takes a Postgres advisory lock to prevent two deploys racing, and it runs DDL that expects a stable session — a transaction pooler hands you a different backend per transaction, so the lock is taken on one connection and looked for on another, and the migration waits forever. The fix is the directUrl field in your datasource block, pointing at the database directly on its non-pooled port, while url keeps pointing at the pooler for runtime queries. Make sure your CI environment actually has that direct URL and network access to use it — a common variant of this bug is a migration step that works locally and hangs in CI because only the pooled host is reachable from there.

What is the best Postgres for Prisma on serverless?

One with a transaction pooler in front of it that handles prepared statements correctly, and located in the same region as your functions. Neon is the common pick because it bundles the pooler, supports branching, and scales to zero, which matches the serverless cost profile well. Supabase works equally well if you want its auth and storage. RDS with RDS Proxy is the conservative answer inside AWS. What matters more than the vendor is the two configuration details: connection_limit=1 in the Prisma connection string so each instance does not hold its own pool, and pgbouncer=true so Prisma avoids named prepared statements. And check the region — a function 200 milliseconds from its database will feel slow no matter which provider's logo is on it, and that latency is invisible in every benchmark that measures the database alone.

How do I get database branching if my provider does not support it?

Build the cheap version, which is often enough. Create an empty database per environment, run prisma migrate deploy to bring the schema up, then load a seed dataset — a Prisma seed script or a pg_restore of a trimmed dump. This works on every provider, costs nothing to implement, and gives you a genuinely clean environment per pull request, which is the property you actually wanted. Its limit is data volume: once your realistic seed is gigabytes, restoring it per pull request stops being fast and you want storage-level copy-on-write branching, which is a provider feature you cannot emulate. One thing to watch for either way: a freshly created database has a cold cache, so the first queries read from storage and the environment feels sluggish for a minute in a way that is nothing to do with your code.

Keep reading

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.