Back to blog
engineering

Why Per-User SQLite Wins for B2C SaaS

By Daniel Chavez

Why Per-User SQLite

Most B2C SaaS apps use a single shared database with a user_id column on every table. It works, but it comes with trade-offs: you need row-level security, complex queries, and careful indexing to avoid one user's data leaking into another's queries.

Per-user SQLite flips this model on its head.

How It Works

Each user gets their own SQLite file at data/users/{userId}/user.db. When the user logs in, their database is opened and migrations are run if needed. All queries for that user's data hit their own isolated file.

data/
  meta.db          ← shared: users, sessions, subscriptions
  users/
    abc123/
      user.db      ← user A's data
    def456/
      user.db      ← user B's data

The Benefits

True isolation

No WHERE user_id = ? required. Every query runs against a database that only contains that user's data. Cross-user data leaks are architecturally impossible.

Performance

SQLite is extremely fast for single-user workloads. Write-ahead logging (SQLite's default write mode) lets reads keep happening while a write is in progress, instead of blocking. With per-user files, each user's recently-read data stays in its own dedicated slice of memory, ready for the next request, no other user's traffic ever pushes it out to make room for their own.

Simple backups

Want to backup or export a user's data? Copy one file. Done.

Easy migrations

Migrations run lazily when a user's database is first opened. You can add columns, create tables, and evolve the schema without coordinating a global migration run.

The Trade-off

Per-user SQLite buys isolation, speed, and zero query-time tenancy logic by giving up something in return: cheap queries across users. No JOIN spans two people's data, because there's no single database that holds both. That's a great deal for some products and a bad one for others.

Best Use Cases for Warpkit

This pattern fits products where each user's data is its own independent silo:

  • Personal productivity tools, note apps, journals, habit trackers, personal CRMs
  • Per-user AI tools, chat assistants, personal knowledge bases, document Q&A where each user's history and context stay private by construction
  • Solo analytics and dashboards, personal finance trackers, fitness logs, time trackers
  • Internal tools and admin panels where each operator's workspace is independent
  • API/dev-tool SaaS where usage and data are scoped to one account
  • Content tools where a user's drafts and projects never need to be queried alongside anyone else's

It's the wrong fit, at least today, for products built around one shared graph rather than many independent ones:

  • Social apps, feeds, follows, comments, anything that joins across users at query time
  • Marketplaces, cross-listing search, matching buyers to sellers
  • Real-time collaboration, shared documents, multiplayer boards, live cursors across users
  • B2B tools with shared team workspaces where multiple people write into the same dataset

Rule of thumb: "many users, N independent silos" is close to free isolation with this pattern. "Many users, one shared graph" fights it the whole way, and a shared-schema database is the right call.

Postgres Is Now Available Too

That second category, shared-graph products, now has its own fork: a Postgres/Drizzle version of this same template, same auth/billing/jobs patterns, different tradeoff. It's included in the same purchase, and it's live: see the Postgres docs or the comparison post for when to reach for it instead of this one. This template keeps optimizing for the isolated, per-user shape it was built for; the fork exists for the products that need the other shape, not to replace this one.

Getting Started

Your per-user migrations live in src/db/user-migrations.ts. Add a new entry to the USER_MIGRATIONS array for each schema change:

{
  id: '002_add_tags',
  run: (db) => {
    db.run(`ALTER TABLE example_items ADD COLUMN tags TEXT`)
  }
}

Migrations are idempotent and run in order. The framework tracks which ones have been applied in a _warpkit_migrations table inside each user's database.