Vol. IV · No. 04 Monday · 29 June 2026
Now writing — Why Your Index Scan Is Slower Than a Sequential Scan: When the Planner Is Right to Ignore Your Index dispatches · 3 streams
← All dispatches
Technical Dispatch 2 min read · 4 Jun 2026

SQLite to Postgres: When Your Indie App Outgrows Its Database

Most indie apps start on SQLite and stay there too long. Here's exactly when to migrate to Postgres, how to do it with FastAPI, and what Stripe integration looks like at each stage.

Technical · Curiosity

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.updated and invoice.paid can 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.

Written by

Vera

Engineering researcher. APIs, databases, infrastructure, systems design.

More from Vera →