Short answer, ownership versus platform versus simplicity #
Choose Cloudflare D1 when you want the simplest durable storage for an edge app: SQL semantics you already know, no server to operate, a natural fit for users, sessions, and app records.
Choose Supabase when you want managed Postgres plus an integrated platform: database, auth helpers, file storage, realtime subscriptions, and policy tooling in one place.
Choose direct Postgres when you must own the database itself: custom extensions, existing infrastructure, migration control, or compliance rules that forbid a platform.
New apps on the edge should default to D1. Move only when product requirements pull you off it, with evidence in hand.
What is each option? #
D1 is Cloudflare's managed serverless database with SQLite semantics, built for queries from Workers and Pages projects, with built-in disaster recovery and access over Worker bindings or HTTP API, as stated in the Cloudflare docs (D1 docs). You create thousands of databases at no extra cost, which suits platforms that isolate tenants per database, and pricing follows queries and storage rather than reserved capacity.
Postgres is the full relational database: rich SQL, constraints, transactions, extensions, and decades of operational knowledge. Run it yourself or through a provider. The power arrives with responsibility for backups, upgrades, monitoring, and connection management, unless someone else carries that pager.
Supabase is a managed platform built on Postgres. Beyond the database it provides auth, file storage, realtime, and edge functions. It is Postgres plus an opinionated backend around it, with client libraries that wrap Data APIs and handle authentication, as stated in the Supabase docs (connecting to Postgres).
Drizzle ORM spans all three paths. It is a headless TypeScript ORM with relational and SQL-like query APIs, dialect support across Postgres, MySQL, and SQLite, and zero dependencies by design, as stated in the Drizzle docs (overview). If you know SQL, the learning curve stays near flat. The adapter and connection pattern still differ per runtime, and that difference is where serverless apps usually break.
Comparison table #
| Criterion | Cloudflare D1 | Supabase | Direct Postgres |
|---|---|---|---|
| Mental model | SQLite at the edge | Managed Postgres plus services | Postgres you operate |
| Operational burden | Low | Low to medium | High |
| Auth, storage, realtime included | No, compose separately | Yes, platform primitives | No, build or integrate |
| Row-level security | Application checks | First-class database RLS | Available, you configure it |
| Backups and recovery | Built in, 30-day Time Travel | Managed backups | Your runbooks |
| Serverless connections | Binding model, no pooling pain | Transaction pooler required | Pooling plus ops discipline |
| Portability and exit | Export code | Postgres foundation eases export | Highest control, you own everything |
No row wins everywhere. Simplicity trades against platform breadth, which trades against direct control.
Which decision rules actually hold? #
Stay on D1 while the schema stays simple: users, sessions, saved records, straightforward relations. D1 sweetens the deal with 30-day point-in-time recovery through Time Travel and global read replicas that lower read latency by copying data closer to readers, as stated in the Cloudflare docs (D1 docs). If realtime channels, cross-service policies, or Postgres-only features have not appeared, the default holds.
Move to Supabase when you can name the platform feature you need. Managed Postgres plus auth helpers. File storage with access policies. Realtime subscriptions on database changes. Row-level security enforced where no application bug can bypass it. Vague scalability feelings do not count. Named features count.
Run direct Postgres when ownership is the requirement. Existing Postgres fleets, specific extensions, network topology mandates, backup regimes auditors must see. Budget honestly: pooling, migrations, monitoring, upgrades, on-call rotations. Control is never free.
A useful graduation signal is access-control complexity. Simple per-user ownership fits D1 plus application checks. Declarative per-row rules across many roles points toward database-enforced policies.
Try it: Tech Stack Picker
Where does row level security earn its keep? #
Postgres restricts rows per user through policies that ride alongside the standard grant system. When enabled on a table, every row read or write must pass a policy, and tables without policies default to denying everything, as stated in the Postgres docs (row security policies). Policies can target commands, roles, or both, and combine with OR for permissive rules or AND for restrictive ones.
ALTER TABLE accounts ENABLE ROW LEVEL SECURITY;
CREATE POLICY account_managers ON accounts TO managers
USING (manager = current_user);Supabase turns this primitive into a workflow. Enable RLS on every table in an exposed schema, revoke the automatic grants, grant back only what each role needs, write one policy per operation, and prove it with database tests, as stated in the Supabase docs (row level security). A table in an exposed schema without RLS stays readable and writable by any role holding a grant, which the docs flag as an explicit danger, as stated in the Supabase docs (row level security).
alter table profiles enable row level security;
revoke all on table profiles from anon, authenticated;
grant select, insert, update, delete on table profiles to authenticated;
create policy "Users can view their own profile."
on profiles for select
to authenticated
using ( (select auth.uid()) = user_id );Because policies run inside the database, they protect data even when reached through third-party tooling. That defense-in-depth quality is the real argument. Application checks work until the one code path that forgot them.
D1 has no equivalent primitive. Application code owns authorization, which is fine for simple ownership and risky for complex sharing rules. When policies multiply across roles, that is the migration signal.
Connection pooling, where serverless apps die #
Serverless runtimes break the one-server-one-pool assumption. Functions scale out fast, each instance opens connections, and traditional Postgres runs out under burst traffic. Supabase answers with explicit connection guidance: serverless and edge functions use the shared pooler in transaction mode, as stated in the Supabase docs (connecting to Postgres).
The client recipe is precise. Create the client once at module scope. Set the pool to one connection. Turn off prepared statements, which transaction mode does not support. Require SSL. The docs show the shape:
import postgres from 'postgres'
export const sql = postgres(process.env.DATABASE_URL, {
max: 1,
prepare: false,
ssl: 'require',
})Library defaults assume persistent backends and will exhaust the pool from a few dozen warm instances. Raise the pool above one only with evidence of queuing, not optimism.
With Drizzle, match the driver and adapter to the runtime. An adapter that works locally can fail at the edge, and transaction-mode limits on prepared statements, cursors, and session state apply regardless of ORM. The D1 path sidesteps most of this through platform bindings rather than classic connections. Switching to Supabase or direct Postgres makes pooling your responsibility again.
What we learned building this #
Our platform provisions a per-project edge database with schema access and optional managed Postgres with auth plus storage plus realtime. The choice in the post mirrors real BYOB usage: simple ownership stays on the edge database and per row security or realtime points to managed Postgres.
Who this is for (and who should skip it) #
This fits edge apps and SvelteKit builders who want minimal ops for users sessions and app records. If you prefer SQLite semantics with built in Time Travel and global reads D1 is a calm default.
Pick managed Postgres or Supabase when you can name the platform feature you need such as row level security realtime or storage policies enforced in the database. Move when product requirements say so not on vague scale worry.
One limit to know. D1 fits simple app records, but realtime or row level rules point to Postgres sooner. A common mistake is choosing on hypothetical scale instead of a named feature the product needs today.
- Best for developers wanting low ops storage for sessions and app records.
- Best for startups naming a need for row level security or realtime.
- Best for small teams starting on D1 and moving on clear product evidence.
Verdict, default to D1 and graduate on evidence #
New apps: D1. Common case, least operational surface, accounts and saved data handled.
Name the Supabase feature before moving: auth helpers, storage policies, realtime, RLS. Not vibes. Features.
Choose direct Postgres when ownership itself is the requirement, and price the pager honestly.
Revisit when schema, access rules, or realtime needs change. Not before. Databases are easy to adopt and expensive to migrate, so the cheapest correct choice today usually wins tomorrow too.