Comparison
SQLite vs Postgres for a Multi-tenant SvelteKit SaaS
We sell both. That is the only reason to trust this page: a vendor with a stake in Postgres would tell you to use Postgres, and one with a stake in SQLite would tell you the opposite. We ship the same SaaS foundation twice and the only difference is the database driver — so here is an honest comparison, including where each one loses.
The short answer
Pick the database you will be running in six months, not the one that is easiest today. If your app will ever run on more than one instance, start with Postgres — not because SQLite is slow, but because with one writer per file, a second replica is not a slower database, it is a second, different database. If you are confident you will stay single-instance, SQLite is the better engineering choice on every axis except concurrency, including cost.
Feature by feature
| Category | SQLite / D1 | Postgres | Verdict |
|---|---|---|---|
| Database engine | SQLite via better-sqlite3 — an in-process, synchronous library. No separate server, no connection string, no process to supervise. | PostgreSQL via postgres.js — a networked service reached over a connection string, with a client-side connection pool. | SQLite is a library; Postgres is a dependency. That single difference drives every row below. |
| Concurrency model | Synchronous and serialized per process. WAL journaling is enabled for file-backed databases so readers are not blocked by a writer. | Many concurrent connections, each with a real session. MVCC lets readers proceed while a writer works. | SQLite serializes writes inside one process. Postgres parallelizes them across processes. |
| Connection handling | None. `createDb(file)` opens the file and returns a handle. There is no pool to size and no idle-connection timeout to tune. | A pooled client sized by `PG_MAX_CONNECTIONS` (default 20), with documented `PG_IDLE_TIMEOUT` and `PG_CONNECT_TIMEOUT` guidance and PgBouncer notes for higher concurrency. | SQLite removes connection management entirely. Postgres requires you to decide a pool size and a proxy strategy. |
| Scaling ceiling | One writer per database file. Two replicas each holding their own file are two different databases, not one database with two readers. | Designed for many application instances against one database. Adding replicas adds app throughput, not data copies. | This is the decisive row. The moment you run more than one instance, SQLite needs to become Postgres. |
| Schema types | Text identifiers, integer timestamps, JSON stored as text. | `uuid` primary keys (app-generated UUIDv4), Unix epoch **milliseconds** as `bigint` (UTC), JSON metadata as text with `jsonb` documented as the upgrade path. | Postgres gives you a wider type system — `jsonb`, native timestamp handling, extensions. SQLite keeps everything simple until it does not. |
| Row-level security | Not available — there is no per-connection identity to attach a policy to. | Opt-in Postgres RLS (`rls/0010_rls_policies.sql`): low-privilege app role, `FORCE RLS`, and a per-request `app.current_user_id` GUC pattern. | Neither kit uses RLS as its tenancy boundary by default — both enforce tenancy in the service layer. Postgres simply lets you add database-level defense-in-depth later. |
| Migrations | Drizzle migrations applied at boot inside `createDb()`. Safe for the same reason the engine is safe: one process, one writer. | Drizzle migrations applied at boot inside `openDb()`, **plus** an explicit `npm run db:migrate` step (`tsx scripts/migrate.ts`) that you run as a pre-deploy step with multiple replicas. | The Postgres kit ships the explicit writer step because concurrent boot-time migrators race. SQLite does not need it — its failure mode is a different database per replica, not a migration race. |
| Deployment target | Cloudflare Pages/Workers with D1 (HTTP API, so no long-lived pool), or any Node host with a writable volume. | Any provider — Neon, Railway, Fly.io, Supabase-direct, or self-hosted via the included `docker-compose.yml` (Postgres 16 on :5433 dev / :5434 test). | SQLite keeps the whole app self-contained. Postgres adds a managed service to your bill and your runbook. |
| Tests | **307 tests** against an in-memory database, with `createDb(':memory:')` giving each test file an isolated instance. | **278 tests** against a real Postgres test database — not a mock, not a SQLite stand-in. | Different counts, different strength: SQLite tests are faster and hermetic; Postgres tests exercise the actual engine you deploy, including its real transaction semantics. |
| Version & maturity | v0.2.9 — the longer-shipped of the two. | v0.1.8 — newer, with the multi-replica migration advisory lock added in the latest release. | Honest caveat: the SQLite kit is the more mature artifact today. The Postgres kit is the younger one and is still gaining features. |
| Price & license | $79 early-bird → $129 standard, one-time, lifetime updates + lifetime standard support, 30-day refund. | $79 early-bird → $129 standard — the same price, on the same terms. | Switching to Postgres is not a upsell. Both kits cost the same, because the only real difference is the database driver. |
The tradeoffs, honestly
What is genuinely identical
The SvelteKit service layer is the same code in both kits: organizations, memberships, single-use hashed invites, RBAC (owner > admin > member), the seat-billing adapter seam, and the append-only audit log. Auth is scrypt with hashed, revocable sessions and a sliding-window failed-attempt limiter in both. If you have read one kit, you know the other — the difference is the driver, not the architecture.
SQLite is the right default, not the consolation prize
For a single-instance app — a self-hosted internal tool, a small SaaS on one Node host, or anything on Cloudflare with D1 — SQLite is genuinely better: no database to provision, no connection pool to tune, no database in your monthly bill, and transactions that are trivially correct because there is one writer. Our own architecture notes say so plainly: better-sqlite3 is synchronous and serialized per process, and WAL mode keeps readers unblocked.
The upgrade trigger is replicas, not traffic
This is the part that gets stated wrong. You do not outgrow SQLite by serving more users. You outgrow it by running **more than one instance** — and then you do not have a slow database, you have *N separate databases*, each with its own file, and your writes start diverging. The moment your deployment has a second replica, the question is no longer "is SQLite fast enough" but "where is the single source of truth". Our architecture docs point the same direction: for multi-instance deployment, move to Postgres; the schema is portable.
What Postgres costs you
Real things, not scare tactics. A managed database is now part of your infrastructure and your budget. You decide a pool size and whether you need a proxy like PgBouncer. Migrations need an explicit single-writer step rather than happening implicitly at boot. And you take on a networked dependency — a database that can be unreachable is a failure mode SQLite simply does not have. In exchange you get one database serving many instances, real MVCC concurrency, `jsonb`, extensions, and the option of opt-in RLS as defense-in-depth.
How to decide in one minute
**One instance, single region, cost sensitivity or edge deployment → SQLite.** **Two or more instances, or a plan to autoscale, or a need for `jsonb`/full-text/read replicas → Postgres.** If you are not sure which instance count you will have in six months, start with SQLite: it is the cheaper mistake, and the schema is portable, so moving later is a driver swap rather than a rewrite.
Which kit is this?
Multi-tenant SvelteKit Starter (SQLite)
v0.2.9 · 307 tests · better-sqlite3 + Drizzle · WAL mode · self-contained deployment
Read the docs →SvelteKit + Postgres Starter (Postgres)
v0.1.8 · 278 tests against real Postgres · postgres.js + Drizzle · opt-in RLS · provider-neutral
Read the docs →Get in touch
Questions about the product, team licenses, or anything else? We'll aim to respond within 48 hours.