Skip to content

Why SQLite — not Postgres

Postgres is the default database for a SaaS starter. It’s also the default reason a starter is hard to deploy: you need a server, a port, credentials, a connection pooler, and a backup strategy that isn’t pg_dump on a cron. Laju Go uses SQLite, and for a starter that targets a single-instance VPS, the tradeoff is almost always in SQLite’s favor.

SQLite is a library, not a server process. The database is a single file on disk. There is no postgres daemon to install, no port to open, no DATABASE_URL to configure, no connection pool to tune against a remote host. Laju Go opens it with one line:

db, err := sql.Open("sqlite3", cfg.DBPath)

The DSN is file:data.db?_foreign_keys=on — a path, not a network address. This means the database lives and dies with the app binary. Deploy is scp laju-go data.db server:. Backup is cp data.db data.db.bak. There is no separate failure mode for “the database is up but the app can’t reach it.”

The old knock on SQLite was write concurrency — a single writer lock. WAL (Write-Ahead Logging) mode fixes this. Readers and writers don’t block each other; writes append to a WAL file and checkpoint asynchronously. Laju Go enables it explicitly, along with a set of PRAGMAs tuned for a single-instance NVMe VPS:

func initDatabase(dbPath string) (*sql.DB, error) {
db, err := sql.Open("sqlite3", dbPath)
// ...
db.SetMaxOpenConns(15)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(5 * time.Minute)
db.SetConnMaxIdleTime(30 * time.Second)
db.Exec("PRAGMA foreign_keys = ON")
db.Exec("PRAGMA journal_mode = WAL")
db.Exec("PRAGMA synchronous = NORMAL")
db.Exec("PRAGMA cache_size = -16000") // 16 MB cache
db.Exec("PRAGMA mmap_size = 268435456") // 256 MB memory-mapped I/O
db.Exec("PRAGMA temp_store = MEMORY")
db.Exec("PRAGMA busy_timeout = 5000") // wait 5s for locks
db.Exec("PRAGMA wal_autocheckpoint = 1000")
return db, nil
}

synchronous = NORMAL trades strict durability (FULL) for a ~2x write speedup while remaining safe against power loss in WAL mode. mmap_size = 256MB lets SQLite memory-map the file so reads bypass the page cache syscall path. busy_timeout = 5000 means writers wait for a lock instead of failing immediately.

On a modern NVMe drive with WAL mode, SQLite sustains tens of thousands of writes per second and far more reads — more than enough for a SaaS starter’s first 10,000 users. The bottleneck for a typical SaaS is not the database’s raw throughput; it’s the application’s query patterns and indexing. SQLite with the right PRAGMAs handles the load that a single-instance app can generate.

Backing up Postgres is a production concern: pg_dump, WAL archiving, PITR, a replica. Backing up SQLite is:

Terminal window
sqlite3 data.db ".backup data.db.bak"
# or, if the app is stopped:
cp data.db data.db.bak

The .backup command uses the online backup API — it works while the app is running and produces a consistent snapshot. For a starter, this is a one-line cron job. There is no replication slot to fill up, no WAL archive to fill the disk.

SQLite is single-instance. The moment you need horizontal scale — two app servers hitting the same database — you need Postgres. Laju Go’s architecture is built for that day: the query layer is generated by sqlc from plain SQL, so swapping SQLite for Postgres is a matter of changing the driver, the schema types, and regenerating. The three-tier rule (Handler → Service → Query) means no business logic touches the database driver directly.

But most SaaS starters never reach that day. Most never reach the point where a single VPS can’t handle the load. SQLite gets you to revenue without a database server, and the migration path is open when you need it.

SQLite is the wrong choice if you need multi-writer concurrency across nodes, if you need Postgres-specific features (JSONB operators, full-text search via tsvector, logical replication), or if you’re deploying on a network filesystem where file locks are unreliable. It is the right choice for a starter that wants to ship on one box, back up with cp, and not run a database server until the business pays for one.