Users, subscriptions, and events, on SQLite and Postgres, both on one cloud VM. The single writer is in the numbers.
On this page
The short answer
SQLite inserted 100,000 events in 217.9 ms on this machine. The same inserts took Postgres 3,070.5 ms, over a local socket, one row at a time. The dashboard query went the other way: 12.8 ms on Postgres and 24.9 ms on SQLite. Eight writers made SQLite slower than one writer, and SQLite still finished first.
When SQLite is enough
SQLite vs Postgres is a file against a server. It lives on the backend and stack hub. SQLite’s own when-to-use page says SQLite competes with fopen(), not with a client/server database. The same page calls 100,000 hits a day a conservative estimate, not a ceiling, and says SQLite has been demonstrated with 10 times that. That demonstration is theirs, from the SQLite site, not a number from this benchmark.
Use SQLite when all of these are true:
- The process that runs SQL is on the same machine as the file.
- Writes can take turns. WAL mode lets readers proceed while one writer appends. There is still one writer at a time, because there is one WAL file.
- A backup you can copy is enough. The backup API and
VACUUM INTOare the documented ways to snapshot a live file. This post did not time either one. - You do not already have a second service that must open the same database.
The default journal mode is a rollback journal. This benchmark switched to WAL and left synchronous at FULL, which read back as 2. Foreign keys are off unless you turn them on. We turned them on. Both engines then rejected an event whose user did not exist. A schema that only works because the check is off is not the schema you will migrate.
Where Postgres wins
Start on Postgres when another computer has to send SQL. SQLite’s when-to-use page says to pick a client/server engine if many clients hit the same database over a network. The file lock has to work on that network filesystem, and SQLite’s page says that is a poor bet. WAL mode has the same limit: every process using the database has to be on one host, because they share memory.
Start on Postgres when writers cannot queue. The same SQLite page says one writer at a time is fine when each write is short and the others can wait, and that a client/server engine handles more write concurrency than SQLite will. The measured version of that is below. On this VM, extra workers slowed SQLite. They made Postgres faster than its own one-worker run, up to four workers, and they never made Postgres faster than SQLite.
Postgres also wins the jobs this timing does not cover. Roles and grants. A dump you can restore on any Postgres, including Supabase, which is hosted Postgres. A connection string an assistant can query. The ContextBolt Postgres MCP page lists a read-only server and says the reference npm package is archived, so check that a maintained server fits before you rely on it. Adding one is the same workflow as adding an MCP server to Claude Code. Keep the password out of any file you commit, including a Claude MD file checked in at the project root.
One timed query did go to Postgres. The dashboard aggregate was 12.8 ms median on Postgres and 24.9 ms on SQLite. Five runs, warm cache, same rows. That is the result. It is not a reason to ignore the insert times above it.
What we tested
Checked
Both databases were on one cloud VM. SQLite was a file opened with Node’s node:sqlite. Postgres was PostgreSQL 16.15 listening on a local Unix socket, client pg 8.23.1. No hosted database. No remote round trip. Node printed its experimental warning for node:sqlite on every run. The Node version was v22.23.4.
The machine, from the files the script read: 4 CPUs, model name Intel(R) Xeon(R) Processor, kernel 6.12.94+, MemTotal 16,387,280 kB. The run started at 09:20:39 UTC and finished at 09:21:25 UTC.
The schema is users, subscriptions, and events. 1,000 users, one subscription each, then 100,000 events. Timestamps are epoch milliseconds in a BIGINT column, so both engines compared the same integers. Plans cycle through free, pro, and team. A user id divisible by 10 is canceled, so 900 subscriptions are active. Event ids, kinds, amounts, and times come from a fixed formula in the script, not from a clock. Schema creation and the user seed are outside the timers. Each test ran five times. The table shows the median, then the slowest and fastest run in parentheses. Medians are rounded to 0.1 ms, except the SQLite SELECT 1 median, which is shown to 0.001 ms so it does not collapse to 0.3.
Postgres kept the apt cluster defaults. shared_buffers was 16,384 units of 8 kB, which is 128 MB. fsync was on, synchronous_commit was on, max_connections was 100. Nothing in postgresql.conf was edited. We did not run ANALYZE. SQLite was WAL, synchronous FULL, foreign_keys ON, busy_timeout 60,000 ms, and wal_autocheckpoint still 1,000. cache_size was left alone and read back as -2000.
| Test | SQLite | Postgres |
|---|---|---|
| 100,000 inserts, one row per statement, one transaction | 217.9 ms (209.6 to 276.3) | 3,070.5 ms (3,042.8 to 3,518.4) |
| 100,000 inserts, 500 rows per statement, one transaction | 246.4 ms (240.9 to 287.8) | 880.0 ms (850.9 to 896.5) |
| 1,000 primary-key reads | 2.9 ms (2.8 to 3.2) | 31.9 ms (29.3 to 39.9) |
| Dashboard query, one execution | 24.9 ms (24.9 to 25.3) | 12.8 ms (12.6 to 13.4) |
| 1,000 times SELECT 1, same open connection | 0.327 ms (0.326 to 0.513) | 22.4 ms (21.9 to 25.7) |
| 20,000 inserts, 1 worker, commit every 100 | 118.6 ms (109.8 to 156.3) | 774.5 ms (762.8 to 781.5) |
| 20,000 inserts, 4 workers, commit every 100 | 166.5 ms (157.9 to 175.3) | 592.9 ms (384.5 to 705.9) |
| 20,000 inserts, 8 workers, commit every 100 | 494.4 ms (295.7 to 702.8) | 779.6 ms (749.4 to 832.6) |
Single-row inserts reuse one prepared statement. The 500-row path builds a new statement for each batch. On SQLite that batched path was slower (246.4 ms against 217.9 ms). On Postgres it was the useful one: 3,070.5 ms down to 880.0 ms. 100,000 divided by the unrounded single-row median, rounded to the nearest row per second, is 458,828 on SQLite and 32,568 on Postgres.
The point reads and the dashboard used the connection that had just loaded the rows, so the cache was warm. Both plans used an index for the primary key. SQLite’s plan said SEARCH events USING INTEGER PRIMARY KEY. Postgres said Index Scan using events_pkey. The dashboard filters created_at to the last seven day-buckets of a 30-day spread that starts September 1, 2026, joins active subscriptions, and groups by plan. Both plans used events_created_at_idx. SQLite then looked up each subscription by user id and built temp b-trees for the group and the distinct count. Postgres hash-joined the events to a sequential scan of the 1,000-row subscriptions table and sorted. Both engines returned the same three rows: free, 7,788 events, 234 users, 19,490,445 cents; pro, 7,755 events, 233 users, 19,394,380 cents; team, 7,788 events, 233 users, 19,429,937 cents.
After that load, SQLite’s main file was 1,445 pages of 4,096 bytes, 5,918,720 bytes. pg_total_relation_size of the three tables was 10,633,216 bytes. Those figures do not include a separate WAL file.
A local socket is not free. Postgres 16’s protocol docs say ReadyForQuery is what tells the client it can safely send the next command. This client waited for each statement. The median for 1,000 SELECT 1 calls was 22.4 ms, rounded. At that pace, 100,000 awaited statements are 2,240 ms, before the statement does the work of an insert. The single-row insert median was 3,070.5 ms. SQLite’s 1,000 SELECT 1 calls took 0.327 ms, which is 32.7 ms at the same 100,000 pace, against a 217.9 ms insert. A hosted Postgres replaces the socket with a network round trip. This run did not measure one, so the post has no remote figure to add.
The script is scripts/benchmarks/sqlite-vs-postgres/bench.mjs. From that directory, npm install and node bench.mjs rewrite results.json. A rerun will not match these medians. The committed file is the run from this VM.
Concurrency and writes
The single writer shows up when the row count stays fixed and the worker count goes up. 20,000 events, disjoint primary keys, a commit every 100 rows. The timer starts after each worker has its own connection. One worker is the same insert pattern with nobody else to wait for.
SQLite went 118.6 ms, then 166.5 ms, then 494.4 ms. Adding workers made it slower. That is the one-writer limit on this workload: the others wait, and the waiting costs more than it saves. Postgres went 774.5 ms, then 592.9 ms, then 779.6 ms. Four workers beat one worker. Eight workers, on a 4-CPU VM, did not beat four. The four-worker Postgres runs were the noisy set: 384.5 ms to 705.9 ms, median 592.9 ms. The eight-worker SQLite runs were noisy too: 295.7 ms to 702.8 ms, median 494.4 ms. The other concurrent rows sat much closer to their medians.
Read the absolute times as well as the shape. At eight workers SQLite’s median was still 494.4 ms and Postgres was 779.6 ms. On a local socket, the single writer did not make SQLite lose. It made extra writers a waste. A hosted Postgres, or a second app server writing at the same time, is a different measurement. This one is the local case.
SQLite write transactions used BEGIN IMMEDIATE, so the writer lock is taken at the start of each 100-row batch. busy_timeout was 60 seconds. No run hit that timeout, and both engines committed all 20,000 rows. The cent totals matched the formula.
Hosted options for a solo founder
The timings above are not a bill, and they are not these services. Prices below were read from the vendor pages on October 26, 2026.
Cloudflare D1 is Cloudflare’s managed database with SQLite’s SQL semantics. The D1 pricing page bills rows read, rows written, and storage. There is no charge for hours, and no egress charge. On the Workers Free plan: 5 million rows read a day, 100,000 rows written a day, 5 GB stored. The Workers Paid plan has a $5 a month minimum. On that plan D1 includes 25 billion rows read a month and 50 million rows written, then $0.001 per million rows read and $1.00 per million rows written. Storage past 5 GB is $0.75 per GB-month. A write to an indexed column counts as an extra row written, because the index is written too. D1’s overview says you can restore a database to any minute in the last 30 days. That restore is their feature. This benchmark did not call D1.
Turso publishes Free at $0: 100 databases, 5 GB, 500 million rows read a month, 10 million rows written, 3 GB of sync. Developer is $5.99 a month, or $4.99 a month billed yearly: unlimited databases, 9 GB, 2.5 billion rows read, 25 million rows written, 10 GB of sync. Overages are off unless you enable them. With overages off, crossing one limit blocks the databases even if the other meters are under the cap. Turso’s docs call the embedded engine SQLite compatible and advertise concurrent writes, and they describe Turso Cloud as the host for Turso and libSQL databases. That concurrent-writer claim is theirs. It was not part of this run. Do not copy the one-writer chart onto Turso.
Supabase is Postgres. Free is $0, with 500 MB of database, two active projects, and a pause after a week with no activity. Pro starts at $25 a month and includes a $10 compute credit, which covers one Micro instance (shared CPU, 1 GB of RAM, $10 before the credit). The worked bill at 1,000, 10,000, and 50,000 users, from the October 24 price list, is Supabase vs Firebase. The plan price on October 26 was still $25. The local Postgres in this post is not a Supabase Micro, and it does not include the network hop to one.
Neon is Postgres. The Free plan is $0: 100 CU-hours per project, 1 GB of Postgres storage per project, scale to zero after 5 minutes, and that suspend cannot be turned off on Free. Neon defines a CU as about 4 GB of RAM, plus CPU and SSD. Their own example: 0.25 CU for 4 hours is 1 CU-hour. Launch is pay for what you use, no monthly minimum: $0.106 per CU-hour and $0.35 per GB-month. Scale compute is $0.222 per CU-hour, with the same storage price. An invoice under $0.50 is not collected. A suspended compute accrues no CU-hours. This post did not run a query against Neon, so it does not turn those unit prices into a monthly total. The free tier next to Supabase, the Launch bill for 100 CU-hours, and the dump in both directions are in Neon vs Supabase. The ORM in front of that connection string, on one small schema, is Drizzle vs Prisma.
Supabase vs Firebase: the bill at 1k, 10k, 50k The monthly bill at 1,000, 10,000, and 50,000 users, from the Supabase and Firebase price lists. Blog Drizzle vs Prisma: bundle size and migrations, measured Bundle size, install footprint, cold start, and the migration commands, on one small schema. BlogMigrating later
The script creates the same tables on both engines. That is the part that makes a later move boring: integer ids, text plans, a BIGINT timestamp both sides can store. A production Postgres schema often uses timestamptz instead. We did not, so the comparison would stay on the same values.
When you do move, stop the writers first. SQLite’s backup page documents two copy tools that do not require you to invent a format: the online backup API, which can copy while other connections keep using the file, and VACUUM INTO, which writes a vacuumed copy to a second file. The older technique is a shared lock, a file copy, then releasing the lock. Writers wait while that lock is held. This post did not time a copy.
Load the rows into Postgres with SQL or COPY. Then point the app at the connection string. The rows that surprise people are the ones SQLite accepted and Postgres will not: a column that holds both numbers and text, or a foreign key that was never enforced because the pragma was off. Turn the pragma on before you have a year of rows, not after.
D1 and Turso can take a SQLite file, on their terms, not on this benchmark’s clock. Supabase and Neon take Postgres. Leaving Supabase is a Postgres dump. That path, and what the backup does not include, is in the Supabase comparison above.
A decision checklist
- One process, one machine, writes can queue: SQLite, WAL on, foreign keys on. On this VM that was the faster insert and the faster point read.
- The dashboard query is the hot path and the box is local: measure it. Here Postgres was faster, 12.8 ms against 24.9 ms.
- A second machine must run SQL against the same data: Postgres. Do not put the SQLite file on a network filesystem.
- Several workers must commit at once and they cannot take turns: Postgres. Here, eight SQLite workers were slower than one (494.4 ms against 118.6 ms) on the same 20,000 rows.
- You want a host tonight and the shape is still SQLite: D1 or Turso, at the prices above. Their engines are not the SQLite process this script timed.
- You want hosted Postgres: Neon if you want scale-to-zero and no monthly minimum, Supabase Pro if you want the $25 plan and the auth and storage that come with it.
- An assistant should query it: use a read-only role, and a Postgres MCP server you have checked is still maintained.
Too soon
Hosted Postgres before a second writer exists
You pay for a server, and every statement waits on a network hop, while one process could have used a file.
Fits
SQLite on the app machine, WAL on
- one writer
- local file
- foreign keys on
That file is a cost. What those users pay you is a different number. How to calculate MRR is the monthly price of the subscriptions you still have, with trials, tax, and one-off fees left out.
How to calculate MRR, and what not to count The monthly price of the subscriptions you still have. Trials, tax, and one-off fees stay out. GuideFAQ
Is SQLite faster than Postgres?
On this one machine, for these queries, SQLite was faster at inserts, primary-key reads, and a trivial SELECT 1. Postgres was faster at the dashboard query: 12.8 ms median against 24.9 ms. Neither number is a hosted database. The runs are in the post.
Can SQLite handle more than one writer?
One writer at a time. In this test, 20,000 rows with a commit every 100 took 118.6 ms with one worker, 166.5 ms with four, and 494.4 ms with eight. The same work on Postgres got faster from one worker (774.5 ms) to four (592.9 ms), then the eight-worker median was 779.6 ms. SQLite was still quicker in every one of those medians. The eight-worker SQLite runs ranged from 295.7 ms to 702.8 ms.
Does this include network latency to a hosted database?
No. Postgres used a Unix socket on the same VM. A hosted Postgres adds a network round trip to every statement the client waits on. This run did not measure that hop, so there is no remote millisecond figure here.
Is Cloudflare D1 the same SQLite as this test?
D1 uses SQLite’s SQL semantics, and it is a hosted service with its own row billing. Turso’s docs call their engine SQLite compatible and advertise concurrent writes. The timings in this post are local SQLite 3.53.4 and local PostgreSQL 16.15. They are not D1, Turso, Supabase, or Neon.
When should a solo founder pay for Postgres?
When another machine has to open the database, or when writers cannot take turns. Supabase Pro starts at $25 a month. Neon Launch has no monthly minimum and charges $0.106 per CU-hour. Those prices were on their sites on October 26, 2026. The worked Supabase bill at three user counts is in Supabase vs Firebase.
Can I move from SQLite to Postgres later?
Yes. Stop writes, take a consistent SQLite snapshot with the backup API or VACUUM INTO, and load that data into Postgres. This post did not time that copy. Loose typing and a foreign key you left off are the usual surprises, not the speed of the file.
Sources
- Write-Ahead Logging (SQLite) Checked
- Appropriate Uses For SQLite Checked
- SQLite Backup API Checked
- Message Flow (PostgreSQL 16) Checked
- Cloudflare D1 pricing Checked
- Cloudflare D1 overview Checked
- Cloudflare Workers pricing Checked
- Turso pricing Checked
- Turso documentation introduction Checked
- Supabase pricing Checked
- Neon pricing Checked
- PostgreSQL MCP server (ContextBolt directory) Checked