MySQL vs SQLite: how far SQLite can go before it ruins your site

Was this helpful?

Everything is fine until it isn’t. Your site launches on SQLite, it’s fast, deployment is frictionless, and the database is literally a file. Then traffic picks up, background jobs show up, analytics gets bolted on, and suddenly you’re staring at database is locked like it’s a personality trait.

This is the line between “SQLite is perfect” and “SQLite is ruining my day.” The goal here isn’t to dunk on SQLite—it’s to put hard edges around where it shines, where it breaks, and what to check before you panic-migrate to MySQL (or, worse, before you try to “fix” SQLite into being MySQL).

The short version: pick the tool like an adult

Use SQLite when you want a single-node app with simple operations, modest write concurrency, and a strong desire to avoid running a database server. It’s excellent for prototypes, internal tools, edge devices, and plenty of production sites that are read-heavy with controlled writes.

Use MySQL when you need predictable concurrency under load, multiple app servers writing at once, replication/failover primitives, online schema evolution habits, and operational knobs that help you recover from mistakes without downtime.

If you can’t describe your write concurrency, your p95 latency target, your backup/restore RTO, and where your database file physically lives, you are not choosing a database—you’re choosing a future incident.

What you’re really choosing: architecture, not syntax

SQLite is a library that writes a file; MySQL is a service that speaks over a network

SQLite runs in-process. Your application calls into a library, and that library reads/writes a database file on local storage. There is no separate database daemon accepting connections, managing memory across clients, or coordinating remote access. That’s not a drawback; that’s the feature.

MySQL runs out-of-process. It owns the data files, buffer pool, redo/undo logs, background threads, and replication. Your app connects over TCP (or a local socket), and MySQL arbitrates concurrency across many clients.

The real trade: operational simplicity vs concurrency and multi-node reality

SQLite’s simplicity is a force multiplier until you outgrow it. One file. Easy backups (mostly). No credentials sprawl. No connection pools to tune. If you’re building a site where writes are rare and controlled, SQLite stays boring in the best way.

But the moment you have multiple writers from multiple processes (or multiple hosts), you’ve moved from “database choice” to “distributed locking and failure semantics.” SQLite can still work, but you must respect its concurrency model and the physics of the storage underneath.

A reliability principle worth stapling to the wall

Werner Vogels, in the AWS reliability world, has a line that gets paraphrased a lot: Everything fails all the time; design so the system continues to work anyway (paraphrased idea, Werner Vogels). SQLite and MySQL both fail. They just fail differently. Pick the failure you can survive.

Interesting facts and history you can use in meetings

  • SQLite was created in 2000 by D. Richard Hipp for a U.S. Navy contract—embedded reliability was the point, not web-scale glamour.
  • SQLite is famously “serverless”—not “cloud serverless,” but “there is no server process.” It’s a library.
  • SQLite is in more devices than you can count: phones, browsers, desktop apps, infotainment systems. That distribution pressure keeps it conservative and stable.
  • WAL mode (Write-Ahead Logging) arrived in 2010 and changed SQLite’s concurrency story dramatically for read-heavy workloads.
  • SQLite databases are single files (plus optional WAL and shm files), which makes shipping and snapshotting easy—but also makes “put it on NFS” a recurring tragedy.
  • MySQL’s InnoDB became the default starting with MySQL 5.5, and that shift mattered: transactions, crash recovery, row-level locks, and sane defaults became the normal experience.
  • MySQL replication has been a core pattern for decades—not perfect, but operationally understood by a huge ecosystem (and most tooling assumes it).
  • SQLite has a surprisingly strict test culture: enormous test coverage, fuzzing, and long-term stability expectations because it’s embedded everywhere.
  • MySQL has a “footgun” history around non-deterministic defaults and configuration drift—less so now, but the folklore exists for a reason.

How far SQLite can go (it’s farther than your team thinks)

SQLite is extremely fast when the workload fits

SQLite can be ridiculously fast for reads because there’s no network hop and the query engine sits in the same process as your code. For a single-node app with a warmed page cache, you can get low latency with fewer moving parts. If your application is mostly reading a modest dataset and doing occasional writes, SQLite is not a compromise. It’s a clean solution.

It also handles ACID transactions, foreign keys (when enabled), indexes, and decent query planning. It’s not a toy database. The problem is that people treat it like a toy until it’s production, and then treat it like a distributed database when it’s not.

Where SQLite is a great default

  • Single VM / single container deployment with one primary application process (or a small number of processes) and controlled writes.
  • Read-heavy workloads with periodic batch writes, especially with WAL enabled.
  • Embedded, desktop, edge, and offline-first apps where running MySQL is absurd overhead.
  • Internal tools where you want maximum “just works” and minimal “call the DBA.”
  • Prototypes that might become real, as long as you build migration and backup discipline early.

SQLite can handle real traffic—if you gate writes

If you can funnel writes through a single worker (or a small number of coordinated workers) and keep transactions short, SQLite can serve a surprising amount of read traffic. WAL mode allows readers to proceed while a writer appends to the log. That’s a big deal.

But you must stop thinking in “requests per second” and start thinking in “write contention per second.” Ten thousand read requests can be fine. Ten write requests that collide can set your p95 on fire.

Joke #1: SQLite is like a very competent librarian—quiet, fast, organized. But it still only stamps one checkout slip at a time.

Where SQLite breaks: failure modes that hurt in production

1) Concurrency: the write lock is the headline

SQLite allows multiple readers, but write concurrency is limited. In rollback journal mode, a writer blocks readers at commit time. In WAL mode, readers don’t block writers and writers don’t block readers—but there is still effectively one writer at a time.

The classic symptom is requests failing or stalling under bursty writes. You’ll see SQLITE_BUSY, “database is locked,” or increased latency that looks like random spikes. It’s not random. It’s contention.

Some teams try to “solve” this with longer busy timeouts. That can reduce error rates, and it can also turn a small contention issue into a site-wide latency incident. Congratulations, you traded a 500 error for a 30-second page load.

2) Storage semantics: local disk vs network filesystem

SQLite relies on file locks and predictable filesystem behavior. On a local filesystem (ext4, xfs, APFS, NTFS), that’s mostly fine. On a network filesystem, it can become performance soup or correctness risk depending on locking semantics and caching behavior.

Putting SQLite on NFS because “we want shared storage for multiple app servers” is a common move right before an on-call rotation gets interesting. If you need multi-host writers, you typically need a server database, or you need to implement a proper single-writer pattern with a queue and a dedicated writer service.

3) Operational failure: backups that are “a copy of the file” (until they aren’t)

SQLite backups can be simple: you can copy the database file when it’s consistent. The problem is that people do it when it’s not consistent, or they copy only the main file and forget the WAL. Or they copy during heavy write load without using the right primitives.

MySQL has structured tooling and a cultural norm around backups. SQLite asks you to be careful. Many orgs interpret “be careful” as “we’ll do it later.”

4) Schema changes: small apps become big, and ALTER TABLE becomes political

SQLite supports many schema changes, but not all of them are online-friendly in the way people expect. Some operations require table rebuilds, which can lock or stall a busy site. If you’re doing frequent migrations on a hot database file, plan for it explicitly.

5) Observability: fewer knobs, fewer counters, more guessing

With MySQL you get a process with metrics: buffer pool hit rate, redo log pressure, lock waits, replication delay, slow query logs, performance schema. With SQLite, you’re often instrumenting from the application side. You can do it well, but you must do it intentionally.

6) “We’ll just add another app server” doesn’t work the same

SQLite scales up nicely on a single node: faster CPU, faster SSD, more RAM, tuned PRAGMAs, better queries. It does not scale out by default. If your next step is adding nodes behind a load balancer, MySQL (or another server DB) becomes the straightforward choice.

What MySQL buys you (and what it costs)

MySQL buys you: predictable multi-client concurrency

MySQL with InnoDB gives you row-level locks, multiple concurrent writers, and isolation semantics that are easier to reason about at scale. You will still have lock contention, but it’s a different animal: you can see it, analyze it, and mitigate it with indexes, query design, and transaction discipline.

MySQL buys you: replication and failover as a first-class life skill

Even if you never run a fancy topology, having a replica for backups, reporting queries, and disaster recovery is a mature operational pattern. It also gives you options when the primary is unhappy: drain traffic, promote, or at least read from a replica while you fix things.

MySQL costs: operational overhead and sharp corners

You now run a stateful service with configuration, upgrades, security posture, monitoring, disk layout, and capacity planning. You need connection pooling. You need backup verification. You need to think about schema changes and long-running transactions. You’re adopting a system that can serve you well, but it will demand competence.

Joke #2: MySQL is like adopting a dog—loyal, capable, and protective. But you’re now responsible for its diet, mood swings, and occasional barking at 3 a.m.

Fast diagnosis playbook: find the bottleneck quickly

This is the “I’m on call and the site is slow” checklist. The fastest way to waste time is debating databases philosophically while your p95 is screaming.

First: decide if you have contention, I/O, or query pathology

  1. Check error patterns: are you seeing SQLITE_BUSY / “database locked” or MySQL lock waits?
  2. Check latency shape: steady slow (I/O bound) vs spiky stalls (contention) vs “some endpoints terrible” (query/index issue).
  3. Check write rate: did a deploy add writes, background jobs, analytics events, session storage writes, or migrations?

Second: identify where the time is going

  1. App-level timing: time in DB calls vs time elsewhere (rendering, external APIs).
  2. DB-level signals:
    • SQLite: WAL/locking behavior, long transactions, hot tables, vacuum/auto_vacuum issues.
    • MySQL: slow query log, InnoDB status, lock waits, buffer pool pressure, replication lag.
  3. Host-level I/O: is the disk saturated or the filesystem under stress?

Third: pick the lowest-risk mitigation

  • If SQLite is locked: reduce write concurrency, shorten transactions, enable WAL (if safe), batch writes, move writes to a single worker.
  • If MySQL is locked: add/adjust indexes, reduce transaction scope, kill runaway queries, adjust isolation where appropriate.
  • If I/O bound: add RAM (for cache), move to faster SSD/NVMe, reduce fsync pressure, tune MySQL redo logs, tune SQLite synchronous mode cautiously.

Practical tasks: commands, outputs, and decisions (12+)

These are real tasks you can run on Linux hosts and common MySQL/SQLite deployments. Each one includes what the output means and what decision you make next.

Task 1: Confirm where the SQLite database lives and what filesystem it uses

cr0x@server:~$ df -T /var/www/app/db/app.sqlite3
Filesystem     Type  1K-blocks      Used Available Use% Mounted on
/dev/nvme0n1p2 ext4  192152472  81324512 101245120  45% /

Meaning: It’s on local ext4, which is good news. If you see nfs or a FUSE overlay with weird semantics, treat locking and latency as suspicious by default.

Decision: If it’s on NFS and you have multiple writers, stop and redesign: move to MySQL/Postgres, or enforce a single-writer service.

Task 2: Check SQLite file companions (WAL/shm) and size growth

cr0x@server:~$ ls -lh /var/www/app/db/
total 2.3G
-rw-r----- 1 www-data www-data 1.7G Dec 30 10:12 app.sqlite3
-rw-r----- 1 www-data www-data 512M Dec 30 10:12 app.sqlite3-wal
-rw-r----- 1 www-data www-data  32K Dec 30 10:12 app.sqlite3-shm

Meaning: WAL mode is in play (you have a -wal file). A huge WAL file can mean checkpoints aren’t happening, the app has long-lived readers, or checkpointing is misconfigured.

Decision: If WAL grows without bound, investigate long-running transactions/reads and checkpoint strategy; if you can’t control it, MySQL starts looking attractive.

Task 3: Verify SQLite journal mode and synchronous settings

cr0x@server:~$ sqlite3 /var/www/app/db/app.sqlite3 'PRAGMA journal_mode; PRAGMA synchronous;'
wal
2

Meaning: wal is enabled. synchronous=2 means FULL. That’s safer but can increase fsync latency.

Decision: If you’re I/O bound and can tolerate some durability risk, you might consider synchronous=NORMAL—but do it only with a clear failure model and tested recovery.

Task 4: Identify “database is locked” events in application logs

cr0x@server:~$ grep -R "database is locked" -n /var/log/app/ | tail -n 5
/var/log/app/app.log:44182 sqlite error: database is locked (SQLITE_BUSY)
/var/log/app/app.log:44190 sqlite error: database is locked (SQLITE_BUSY)
/var/log/app/app.log:44201 sqlite error: database is locked (SQLITE_BUSY)
/var/log/app/app.log:44222 sqlite error: database is locked (SQLITE_BUSY)
/var/log/app/app.log:44228 sqlite error: database is locked (SQLITE_BUSY)

Meaning: These aren’t “random glitches.” They are concurrency pressure or long transactions.

Decision: If these correlate with specific endpoints or cron jobs, isolate writers. Add a queue, batch writes, or move that workload to MySQL.

Task 5: Find long-running transactions holding SQLite back (via app process inspection)

cr0x@server:~$ ps -eo pid,etimes,cmd | grep -E "gunicorn|uwsgi|node|python" | head
 2143   8123 /usr/bin/python3 /var/www/app/worker.py
 2190   4201 /usr/bin/python3 /var/www/app/web.py
 2211    233 /usr/bin/python3 /var/www/app/web.py

Meaning: Long-lived worker processes often hold open DB connections and can keep read transactions open unintentionally.

Decision: Audit connection/transaction scope. Ensure each request/job uses short transactions and closes cursors promptly. If your ORM opens implicit transactions, force autocommit where safe.

Task 6: Check disk latency and saturation during spikes

cr0x@server:~$ iostat -xz 1 5
Linux 6.5.0 (server)  12/30/2025  _x86_64_ (8 CPU)

avg-cpu:  %user   %nice %system %iowait  %steal   %idle
           6.12    0.00    2.01    8.42    0.00   83.45

Device            r/s     rkB/s   rrqm/s  %rrqm  r_await rareq-sz     w/s     wkB/s   wrqm/s  %wrqm  w_await wareq-sz  aqu-sz  %util
nvme0n1         85.0   2048.0     0.0    0.0    3.20    24.10   210.0   8192.0     0.0    0.0   18.70    39.01    4.12   92.5

Meaning: %util near 90% and w_await ~19ms suggests the disk is a bottleneck for writes. SQLite FULL synchronous will feel that.

Decision: If the disk is saturated, reduce fsync frequency (carefully), batch writes, move DB to faster storage, or move to MySQL with better buffering and log tuning.

Task 7: Check open file locks behavior (useful when NFS is involved)

cr0x@server:~$ lsof /var/www/app/db/app.sqlite3 | head
COMMAND  PID     USER   FD   TYPE DEVICE SIZE/OFF    NODE NAME
python3  2190 www-data    7u   REG  259,2 1825368064 393226 /var/www/app/db/app.sqlite3
python3  2211 www-data    7u   REG  259,2 1825368064 393226 /var/www/app/db/app.sqlite3

Meaning: Multiple processes have the file open. That’s normal, but if those processes are on different hosts and the file is on shared storage, you’re playing with lock semantics.

Decision: If this is multi-host access, stop. Centralize writes or migrate to a server DB.

Task 8: Validate SQLite integrity after a crash or suspicious I/O event

cr0x@server:~$ sqlite3 /var/www/app/db/app.sqlite3 'PRAGMA integrity_check;'
ok

Meaning: “ok” means the internal structures are consistent.

Decision: If it’s not ok, restore from backups immediately and investigate underlying storage and process crash behavior.

Task 9: Observe SQLite query plan for a known slow query

cr0x@server:~$ sqlite3 /var/www/app/db/app.sqlite3 "EXPLAIN QUERY PLAN SELECT * FROM orders WHERE user_id=42 ORDER BY created_at DESC LIMIT 20;"
QUERY PLAN
`--SCAN orders
    `--USE TEMP B-TREE FOR ORDER BY

Meaning: It’s scanning the table and sorting with a temp b-tree. That’s expensive.

Decision: Add an index like (user_id, created_at). Most “SQLite is slow” reports are actually “you forgot an index.” Same for MySQL, by the way.

Task 10: Check MySQL for slow queries (if you already migrated or you’re evaluating)

cr0x@server:~$ sudo mysql -e "SHOW VARIABLES LIKE 'slow_query_log'; SHOW VARIABLES LIKE 'long_query_time';"
+----------------+-------+
| Variable_name  | Value |
+----------------+-------+
| slow_query_log | ON    |
+----------------+-------+
+-----------------+-------+
| Variable_name   | Value |
+-----------------+-------+
| long_query_time | 1.000 |
+-----------------+-------+

Meaning: Slow query log is on; queries slower than 1s are logged.

Decision: If you don’t have this enabled in production, you’re choosing blindness. Turn it on with a sane threshold and rotate logs.

Task 11: Inspect MySQL lock contention and active transactions

cr0x@server:~$ sudo mysql -e "SHOW FULL PROCESSLIST;"
+----+------+-----------+------+---------+------+------------------------+-------------------------------+
| Id | User | Host      | db   | Command | Time | State                  | Info                          |
+----+------+-----------+------+---------+------+------------------------+-------------------------------+
| 17 | app  | 10.0.1.12 | app  | Query   |   12 | Waiting for table lock | UPDATE sessions SET ...       |
| 23 | app  | 10.0.1.13 | app  | Query   |    0 | Sending data           | SELECT * FROM orders WHERE... |
+----+------+-----------+------+---------+------+------------------------+-------------------------------+

Meaning: A query is waiting for a table lock for 12 seconds. That’s not a vibe.

Decision: Find the blocker (often a migration or a long transaction). Fix transaction scope, use online schema change patterns, and add indexes to avoid locking scans.

Task 12: InnoDB health snapshot for MySQL (the “tell me what hurts” command)

cr0x@server:~$ sudo mysql -e "SHOW ENGINE INNODB STATUS\G" | head -n 40
*************************** 1. row ***************************
  Type: InnoDB
  Name:
Status:
=====================================
2025-12-30 10:20:11 0x7f3b6c1fe700 INNODB MONITOR OUTPUT
=====================================
Per second averages calculated from the last 10 seconds
-----------------
BACKGROUND THREAD
-----------------
srv_master_thread loops: 1120 srv_active, 0 srv_shutdown, 332 srv_idle
srv_master_thread log flush and writes: 1452
----------
SEMAPHORES
----------
OS WAIT ARRAY INFO: reservation count 1021
OS WAIT ARRAY INFO: signal count 1004
Mutex spin waits 0, rounds 0, OS waits 0
RW-shared spins 112, OS waits 19
RW-excl spins 88, OS waits 25

Meaning: This output can expose lock waits, buffer pool pressure, and log flush issues. Even a quick skim shows if the engine is waiting on I/O or locks.

Decision: If you see heavy log waits or buffer pool misses, you tune MySQL and storage. If you see lock waits, you tune queries/transactions/schema.

Task 13: Measure MySQL buffer pool hit rate directionally

cr0x@server:~$ sudo mysql -e "SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%';"
+---------------------------------------+------------+
| Variable_name                         | Value      |
+---------------------------------------+------------+
| Innodb_buffer_pool_read_requests      | 982345678  |
| Innodb_buffer_pool_reads              | 1234567    |
+---------------------------------------+------------+

Meaning: Reads vs read_requests indicates cache efficiency. Here, physical reads are a small fraction, which is good.

Decision: If physical reads climb, add RAM, tune buffer pool size, or fix queries/indexes that cause big scans.

Task 14: Confirm MySQL replication lag (if you rely on replicas)

cr0x@server:~$ sudo mysql -e "SHOW SLAVE STATUS\G" | egrep "Seconds_Behind_Master|Slave_IO_Running|Slave_SQL_Running"
Slave_IO_Running: Yes
Slave_SQL_Running: Yes
Seconds_Behind_Master: 27

Meaning: 27 seconds behind. That’s enough to break “read-your-writes” assumptions.

Decision: If your app reads from replicas, you need consistency rules (stickiness, read-after-write routing) or reduce lag via tuning and workload separation.

Task 15: Check that SQLite is not silently using multiple threads in a way you didn’t expect

cr0x@server:~$ sqlite3 /var/www/app/db/app.sqlite3 'PRAGMA compile_options;' | grep -E 'THREADSAFE|OMIT_WAL' | head
THREADSAFE=1

Meaning: SQLite is built threadsafe (good). But thread safety isn’t the same as concurrency scaling; you still have the single-writer constraint.

Decision: If your app assumes “threads = throughput,” redesign write paths before you blame SQLite.

Three corporate mini-stories from the trenches

1) Incident caused by a wrong assumption: “It’s a file, so shared storage solves scaling”

A mid-size company had a web app that started life as a single VM with SQLite. It was fine: a few writes per second, mostly reads, and a simple deployment. Then they added a second web server behind a load balancer. The idea was polite: more capacity, more redundancy. The database stayed as a file on a shared network filesystem because “both servers need access.”

For the first day, it looked okay. Traffic was light, and the shared filesystem was fast enough in the happy path. Then a marketing campaign hit. Write traffic increased: sessions, event tracking, and a small “last seen” table that updated on nearly every request. Suddenly the app started returning intermittent errors. The worst part: the errors were not consistent. Some requests were fast; others stalled and timed out.

The on-call engineer saw SQLITE_BUSY and increased the busy timeout. Errors dropped. Latency doubled. Now the load balancer started marking hosts unhealthy because requests took too long, which concentrated traffic onto fewer nodes, which increased lock contention. This is how you turn a small concurrency issue into a cascading failure with a straight face.

Post-incident, the root cause was two-fold: SQLite locking behavior across networked filesystems was not what they assumed, and multi-host write concurrency was never a supported scaling plan. The fix was boring and correct: move the database to a MySQL instance and treat it as a single authoritative service, then refactor session/event writes to be less chatty.

The lesson was not “SQLite is bad.” The lesson was “shared storage is not a database cluster,” and file-lock semantics are not a scaling strategy.

2) Optimization that backfired: “Turn off durability, it’s faster”

A SaaS team ran SQLite on a beefy VM for a low-latency internal dashboard. Writes were more frequent than they liked, and the disk latency spikes were noticeable. Someone suggested changing PRAGMA synchronous to a less strict setting to reduce fsync costs. The benchmarks looked great. The graphs looked great. Everyone enjoyed the illusion of victory.

Two weeks later, the host had an unplanned reboot during a kernel upgrade. The app came back up quickly. Then users started reporting missing recent changes. Not corrupted database structures—worse. Correct-looking data, but missing the last chunk of writes that were “committed” from the application’s point of view.

The team had accidentally changed the durability contract without changing the business expectations. Users assumed that once the UI confirmed an update, it would survive a crash. In practice, those updates had been living in volatile buffers and never reached stable storage.

The cleanup was painful: reconstructing data from application logs, reconciling user reports, and restoring trust. They reverted to safer settings, added explicit user-facing messaging where eventual durability was acceptable, and introduced a proper MySQL backend for the data that users cared about most.

Optimization that changes correctness is not optimization. It’s a product decision. If you make it accidentally, production will file the paperwork for you.

3) Boring but correct practice that saved the day: tested restores and a “one writer” discipline

A different org ran a content site on SQLite for years. Yes, years. They had a single node handling the database file, and they were strict about write discipline: only one background worker performed writes, and the web tier was read-mostly, with updates queued.

Their biggest operational habit wasn’t fancy tuning. It was restore testing. Every week, a job pulled the latest backup, restored it to a staging host, ran PRAGMA integrity_check, and executed a small suite of application queries. If the restore failed, it paged someone. This was considered normal, not heroic.

One day, a deploy introduced a migration bug that ballooned the database size and pushed the disk near full. The site started slowing, and then writes began failing. They rolled back the deploy, but the database file had already grown. Disk pressure persisted.

They restored from the last known good backup into a fresh filesystem with ample space, replayed a small set of queued writes, and were back online quickly. No data drama. No guessing. The boring practice—verified restores—turned a bad day into a contained incident.

SQLite didn’t save them. Discipline did. SQLite just didn’t get in the way.

Common mistakes: symptom → root cause → fix

Symptom: “database is locked” spikes during traffic bursts
Root cause: Too many concurrent writers, long transactions, or a background job colliding with request writes.
Fix: Enable WAL if appropriate; shorten transaction scope; move writes into a single queue-driven worker; reduce write frequency (debounce session updates).
Symptom: SQLite is fast locally but slow in production
Root cause: Production storage has higher fsync latency; synchronous FULL amplifies it; WAL checkpointing can stall.
Fix: Measure disk latency (iostat); place DB on local SSD; tune checkpointing; consider MySQL if write latency must be stable.
Symptom: Random timeouts, especially during backups
Root cause: Naive file copy during active writes; checkpointing/locking interaction; long-lived readers preventing checkpoint.
Fix: Use SQLite online backup API via tooling; ensure backups include WAL state; schedule backups with write throttling.
Symptom: WAL file grows huge and stays huge
Root cause: Checkpoints can’t complete due to long-lived readers or misconfigured autocheckpoint; application keeps read transactions open.
Fix: Ensure short-lived connections/transactions; set autocheckpoint; explicitly checkpoint during low traffic; review ORM behavior.
Symptom: After adding a second app server, performance collapses
Root cause: SQLite on shared storage with multiple writers; lock contention across processes/hosts; filesystem lock semantics.
Fix: Don’t. Centralize writes, or migrate to MySQL. If you need multiple app servers, a server DB is the normal answer.
Symptom: MySQL is “slow” after migration, worse than SQLite was
Root cause: No indexes, no connection pooling, wrong isolation assumptions, or a schema designed for file-based access patterns.
Fix: Turn on slow query log; add indexes based on query patterns; use a connection pool; right-size InnoDB buffer pool.
Symptom: MySQL CPU is fine, but queries stall
Root cause: Lock waits or I/O waits (redo log fsync pressure, buffer pool misses).
Fix: Use SHOW ENGINE INNODB STATUS; shorten transactions; tune redo log and buffer pool; move hot writes off the primary if possible.

Checklists / step-by-step plan

Step-by-step: deciding whether SQLite is still safe for your site

  1. Map your writers. List every code path that writes: requests, cron, background jobs, analytics, sessions, cache invalidation, admin tools.
  2. Measure write concurrency. Not average writes—peak overlapping writes. If it’s “unknown,” assume it’s “too high” until proven otherwise.
  3. Enable WAL (if not already) and verify it. Confirm PRAGMA journal_mode is WAL and you understand checkpoint behavior.
  4. Enforce short transactions. Ban “open transaction across network call” patterns. If your ORM makes this easy, it’s not a compliment.
  5. Prove your backups. Restore weekly. Run integrity checks. Practice RTO, not just RPO.
  6. Keep the DB on local storage. If you can’t, treat it as a major risk and plan migration.
  7. Plan your scale-out story. If you need multiple app servers writing, schedule the MySQL migration while you’re calm.

Step-by-step: migrating from SQLite to MySQL without making it a career event

  1. Freeze schema semantics. Decide on types, constraints, and defaults explicitly. SQLite is permissive; MySQL will make you choose.
  2. Choose a migration strategy. For small datasets: downtime migration. For larger: dual-write or change capture patterns (harder, but possible).
  3. Build a repeatable export/import. The first run is a rehearsal; the second run is how you sleep at night.
  4. Validate counts and invariants. Row counts by table, checksum critical fields, run application-level consistency checks.
  5. Move reads first (optional). Sometimes you can point read-only endpoints at MySQL replicas while writes still go to SQLite, but beware consistency assumptions.
  6. Switch writes with a clear rollback plan. Rollback isn’t “panic.” It’s a procedure.
  7. Turn on MySQL observability day one. Slow query log, error log, metrics, backups, and restore drills.

FAQ

1) Can SQLite handle “high traffic”?

Yes—if high traffic mostly means reads, and writes are controlled. The question isn’t traffic; it’s concurrent writes and how long they hold locks.

2) Is WAL mode always better?

Usually for web workloads, yes. It improves read concurrency during writes. But it introduces WAL and checkpoint dynamics you must understand, and some edge cases (like certain filesystems or tooling) require care.

3) What’s the clearest sign I should migrate to MySQL?

Multiple app servers writing to the same database, or frequent write contention that you can’t eliminate without distorting the product. Also: when you need replication/failover as a standard tool, not a science project.

4) Can I run SQLite on NFS if I’m careful?

Sometimes, but “careful” needs a definition: verified locking semantics, tested failure modes, and usually single-writer discipline. If you’re doing multi-host concurrent writes, you’re betting your site on filesystem details.

5) Isn’t MySQL “heavier” and slower because of the network?

It adds network overhead, yes. But it buys concurrency control, buffering, and operational tools. For multi-client write-heavy workloads, MySQL often wins in real end-to-end latency because it avoids lock-induced stalls.

6) If SQLite locks on writes, can I just increase the busy timeout?

You can, and it can reduce errors. But it also converts contention into latency. If your site has tight response time SLOs, long busy timeouts are just slow failure with nicer logs.

7) What about using SQLite for sessions or analytics events?

Those are classic write-heavy patterns that create contention. If you insist on SQLite, batch and queue writes, and avoid updating rows on every request. Otherwise use a separate system (MySQL, Redis, or a log pipeline) designed for that write profile.

8) What’s the biggest “gotcha” when moving from SQLite to MySQL?

SQLite’s flexible typing and permissive constraints can hide data quality problems. MySQL will force you to pick types and collations, and it will expose bad assumptions in code and data.

9) Can I use SQLite with multiple processes on one machine?

Yes. That’s a common and valid deployment. But you still need to manage write contention: WAL mode, short transactions, and careful background job scheduling.

10) If I stay on SQLite, what’s the single most important discipline?

Keep writes controlled and transactions short. Then verify backups with restore tests. Those two habits prevent most of the “SQLite ruined our site” stories.

Conclusion: next steps that won’t embarrass you

SQLite is not “for demos.” It’s for workloads that respect its design: single-node, controlled writes, and sensible storage. When it fails, it usually fails because you asked it to behave like a multi-host server database while keeping the convenience of “just a file.” That’s not a technical plan; it’s wishcasting.

Do the practical work:

  • If you’re on SQLite: confirm WAL mode, measure disk latency, audit writers, shorten transactions, and test restores. If you need multiple app servers writing, schedule migration while you still have time.
  • If you’re on MySQL (or moving there): turn on slow query logging, watch lock waits, right-size the buffer pool, and treat backups/restores as a production feature, not a compliance checkbox.
  • Either way: make your database choice based on concurrency and failure recovery, not vibes.
← Previous
Why Intel adopted AMD64 (and why it changed everything)
Next →
ZFS Recordsize + Compression: The Combo That Changes CPU and Disk Math

Leave a comment