SQLite is the perfect database to start with. Zero config, zero ops, and fast enough for thousands of users. But there comes a moment — usually around concurrent writes or when you need row-level locking — where you hit the ceiling.
This is that playbook.
Stage 1: SQLite is Fine (0–10k users)
Stop apologizing for SQLite. It powers more production systems than most engineers realize. For a FastAPI indie app, SQLAlchemy with aiosqlite covers 90% of use cases:
from sqlalchemy.ext.asyncio import create_async_engine
engine = create_async_engine("sqlite+aiosqlite:///./app.db")
At this stage your Stripe integration is usually one webhook endpoint and a customers table. SQLite handles concurrent reads fine — the problem is concurrent writes, and most SaaS products simply don't have them at this stage.
Stage 2: The Warning Signs
Watch for these before you hit a wall:
- Lock timeouts — SQLite's write lock is database-wide. Any concurrent write operation that takes more than a few hundred ms will start failing.
- Stripe webhooks colliding — Payment events often arrive in bursts.
customer.subscription.updatedandinvoice.paidcan fire within milliseconds of each other, both trying to write. - File size approaching 1GB — Not a hard limit, but WAL mode becomes important and backup complexity grows.
Stage 3: Migration to Postgres
The migration itself is usually one afternoon. The mental overhead is longer. Here's the actual work:
1. Swap the connection string
# Before
DATABASE_URL=sqlite+aiosqlite:///./app.db
# After
DATABASE_URL=postgresql+asyncpg://user:pass@localhost/myapp
2. Fix column types — SQLite is type-flexible; Postgres is strict. Common culprits: BOOLEAN stored as integers, DATETIME without timezone, JSON stored as text.
3. Handle migrations properly
alembic init migrations
# Edit alembic.ini to use env var
sqlalchemy.url = %(DATABASE_URL)s
4. Export and import data
sqlite3 app.db .dump > dump.sql
# Transform types, then:
psql myapp < transformed_dump.sql
Stripe at the Postgres Stage
Once you're on Postgres, your Stripe architecture should mature too. Two patterns that matter:
Idempotency keys — Stripe events can be delivered more than once. Postgres gives you the transaction isolation to handle this safely:
async with session.begin():
existing = await session.execute(
select(WebhookEvent).where(WebhookEvent.stripe_event_id == event.id)
)
if existing.scalar():
return # Already processed
session.add(WebhookEvent(stripe_event_id=event.id))
# ... process the event
Subscription state machine — Postgres row-level locking lets you safely transition subscription states without race conditions:
async with session.begin():
sub = await session.execute(
select(Subscription)
.where(Subscription.stripe_subscription_id == sub_id)
.with_for_update()
)
The Real Migration Cost
The technical migration takes hours. The operational cost — backups, connection pooling with pgbouncer, monitoring — takes weeks to get right. Start simple: a managed Postgres on Railway or Supabase removes most of the ops burden.
If you're building an indie product and wondering whether you're at the SQLite-to-Postgres inflection point, check out Builds — a directory of indie products at every stage of this exact journey.