SQLite is the database equivalent of a well-made pocketknife. It’s compact, sharp, and somehow always there when you need it.
Then one day you try to build a house with it, and the handle starts to hurt.
This is the point where teams start arguing in Slack: “SQLite is fine.” “No, it’s the bottleneck.” “We just need indexes.”
Meanwhile users are watching spinning loaders, and your on-call phone is warming up.
The real difference: file database vs server database (and why it matters)
SQLite and MySQL both speak SQL. That shared vocabulary misleads people into thinking they’re interchangeable.
They are not. The first difference isn’t syntax, features, or even speed. It’s architecture.
SQLite: a library with a file
SQLite is an embedded database engine. It lives inside your process and stores data in a single file (plus sidecar files like
-wal and -shm when using WAL mode). There’s no database server to connect to. Your application reads and writes
bytes through the SQLite library, directly, using filesystem semantics as the durability boundary.
This is why SQLite is easy to ship, easy to test, and surprisingly fast on a single machine with simple concurrency.
It’s also why the filesystem and storage subsystem become part of your database correctness story—whether you like it or not.
MySQL: a separate service with process-level concurrency
MySQL is a server. It’s a separate process with its own memory management, thread pool behavior, internal locks, redo logs, buffer pool,
replication capabilities, and a network protocol. Your application sends requests; MySQL schedules them, coordinates concurrency, and persists
changes through its storage engine (typically InnoDB).
This separation gives you a clean operational surface: you can monitor it, tune it, replicate it, back it up without stopping the world, and
isolate database load from application load. It also introduces operational overhead. Congratulations, you now own a database service.
What that means in production
If your app is a single process on a single machine with light write concurrency, SQLite can be a fantastic choice.
If you have multiple app instances, a queue of writes, strict durability expectations, or any ambition for high availability, SQLite’s “it’s just a file”
becomes “it’s just a file… shared across a distributed system,” and that sentence ends with an incident report.
The decision isn’t “SQLite is toy, MySQL is real.” The decision is whether your system’s failure modes are better handled by
file-level locking and OS semantics (SQLite) or by a purpose-built concurrency and durability layer with operational tooling (MySQL).
Paraphrased idea from Werner Vogels (Amazon CTO): you should plan for failure rather than pretending it won’t happen. Databases are where pretending gets expensive.
Facts & history that explain today’s failure modes
- SQLite was created in 2000 by D. Richard Hipp, initially to support internal tooling. It was designed to be embedded and simple to deploy.
- SQLite’s “public domain” style licensing is a major reason it’s everywhere—vendors don’t need legal gymnastics to ship it in products.
- SQLite is the default database in many mobile stacks because it’s small, reliable on a single device, and doesn’t require a server.
- WAL mode (write-ahead logging) in SQLite improved concurrency substantially, but it still doesn’t give you “many writers” in the way server databases do.
- MySQL began in the mid-1990s and grew up in web hosting, where many concurrent clients and long-running services are the norm.
- InnoDB became the default MySQL storage engine starting in MySQL 5.5, largely because it offered transactions, row-level locking, and crash recovery.
- Replication shaped MySQL’s operational identity: asynchronous replicas, read scaling, and failover patterns became standard practice in web operations.
- SQLite’s correctness relies on filesystem guarantees. Most local filesystems are fine; network filesystems and “creative” storage layers can get weird.
Migration signs: exactly when SQLite has outgrown the job
1) You’re seeing “database is locked” under real load
SQLite can handle multiple readers, and with WAL it can handle a reader while a writer is active. But write concurrency is the cliff.
One writer at a time. If your workload has bursts of writes—sessions, events, counters, inbox states, job queues—your tail latency will balloon.
The tell: requests don’t fail immediately; they hang. Your p95 becomes your p99. Then users complain. Then you add retries.
Then you amplify the thundering herd. This is the point you should stop negotiating with physics and start planning the move.
2) You scaled the app horizontally, and SQLite became a shared-file problem
Running SQLite with multiple app instances works only when each instance has its own database file (per-tenant, per-node, or per-device).
The moment you put one SQLite file on shared storage for multiple instances, you’ve created a distributed locking and consistency problem.
Even if it “works in staging,” it can degrade into lock storms, stale reads, or corruption risks depending on your storage layer.
MySQL exists specifically so you don’t have to bet your uptime on how your NFS mount behaves during packet loss.
3) You need high availability, not just backups
Backups are not high availability. SQLite is great for backups because the artifact is a file, but failover is not a file copy problem.
If your business requires continuing to accept writes during node failure, you want replication, election/failover tooling, and a place to attach
operational policies. That’s MySQL territory.
4) You need predictable latency under mixed workloads
SQLite can be blazing fast for point reads and small transactions. But when one query goes big—an accidental full scan, a missing index,
a vacuum-like operation depending on your patterns—your whole process can suffer because the database engine is inside it.
With MySQL, the database has its own memory and scheduling behavior. You can isolate and tune. You can kill a query. You can set per-user limits.
You can keep your app from becoming collateral damage.
5) You’re fighting durability and backup semantics
SQLite’s durability depends on correct use of transactions and the underlying storage honoring fsync semantics. Many teams
unknowingly set pragmas that trade durability for speed. Then they’re surprised when a crash eats recent writes.
If you’re at the point of arguing about synchronous settings and “how risky is it really,” you’re already paying the cognitive cost.
MySQL gives you industry-standard durability controls and established backup/failover patterns.
6) You need operational observability you can hand to on-call
SQLite doesn’t come with built-in performance schema, slow query logs, replication status, buffer pool metrics, or standardized admin commands.
You can instrument it, but you’re building your own database ops layer.
If on-call needs to answer “what is the database doing right now?” and the best you can offer is “I can add some logs and redeploy,”
that’s not an operations strategy. That’s hope with extra steps.
7) Your data model outgrew “one file” operationally
SQLite’s single-file nature is convenient until it becomes a deployment artifact. Shipping, migrating, locking, copying, and validating that file
turns into a high-stakes ritual. With MySQL, schema migrations are still risky, but they’re at least part of a world with mature tooling,
online migration approaches, and established playbooks.
8) You’re building multi-tenant or access-controlled data flows
SQLite has no user accounts, no network-level authentication, and no fine-grained privilege model like a server database.
If your security posture needs real separation of duties or audit-friendly access control, MySQL is the more natural fit.
Joke #1: SQLite is like a very efficient bouncer for a tiny club—until your app invites the whole internet and insists it’s still “a small venue.”
Fast diagnosis playbook: find the bottleneck in 15 minutes
The job isn’t to “decide MySQL is better.” The job is to prove what is failing. Here’s the order that saves time.
First: confirm the symptom category (locking, IO, CPU, or query design)
- Check for lock contention: errors like
database is locked, timeouts, or long waits around writes. - Check storage latency: elevated disk await, fsync stalls, or saturated IOPS.
- Check CPU: single-core pegging in the app process (SQLite runs in-process), or query execution time rising with CPU usage.
- Check query plans: full scans and missing indexes. SQLite will happily do the wrong thing quickly until it doesn’t.
Second: reproduce with a controlled benchmark
Use a write-heavy and a read-heavy test. Measure p95/p99 latency, not just throughput. SQLite often looks fine until tail latency gets ugly.
Third: decide whether the fix is tactical or strategic
- Tactical fix: add an index, reduce write frequency, batch writes, enable WAL, set a busy timeout, or change transaction boundaries.
- Strategic fix: move to MySQL when you need concurrent writes, HA, better operational controls, or predictable performance under multiple clients.
Practical tasks: commands, outputs, and decisions (12+)
These are the “show me” checks. Each task includes a command, what the output means, and what you do next.
The commands assume a Linux host with a SQLite file at /var/lib/app/app.db and a MySQL service if you’re comparing.
Task 1: Confirm WAL mode and synchronous settings (durability vs speed)
cr0x@server:~$ sqlite3 /var/lib/app/app.db 'PRAGMA journal_mode; PRAGMA synchronous;'
wal
2
What it means: wal improves concurrency for readers vs writers. synchronous=2 is FULL (safer).
If you see off or 0, you may be trading crash safety for performance.
Decision: If you need durability and are using synchronous=OFF, fix that first. If FULL hurts performance and writes are frequent, that’s a migration signal.
Task 2: Check busy timeout (how you behave under contention)
cr0x@server:~$ sqlite3 /var/lib/app/app.db 'PRAGMA busy_timeout;'
0
What it means: 0 means “fail immediately” under lock contention (or bubble errors rapidly).
Decision: Set a sane busy timeout in the application (and possibly in DB pragmas) if lock spikes are minor. If you need long busy timeouts to survive, you’re papering over a one-writer architecture.
Task 3: Identify hot tables and index coverage quickly
cr0x@server:~$ sqlite3 /var/lib/app/app.db ".schema" | sed -n '1,40p'
CREATE TABLE events(
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
payload TEXT NOT NULL
);
CREATE INDEX idx_events_user_created ON events(user_id, created_at);
What it means: You’re checking whether the obvious access paths exist. Lack of a composite index is a common reason SQLite “suddenly got slow.”
Decision: If performance problems are due to missing indexes, you can often delay migration. If indexing helps reads but writes remain blocked, you still migrate.
Task 4: Explain query plan (catch the full scan)
cr0x@server:~$ sqlite3 /var/lib/app/app.db "EXPLAIN QUERY PLAN SELECT * FROM events WHERE user_id=42 ORDER BY created_at DESC LIMIT 50;"
QUERY PLAN
`--SEARCH events USING INDEX idx_events_user_created (user_id=?)
What it means: This is good: index search, not full scan.
Decision: If you see SCAN TABLE on a large table in prod paths, fix schema/query first. Don’t migrate just to avoid adding an index.
Task 5: Check database size and page statistics (growth and IO pressure)
cr0x@server:~$ sqlite3 /var/lib/app/app.db 'PRAGMA page_size; PRAGMA page_count;'
4096
258000
What it means: Approx size is page_size * page_count (~1.0 GiB here). Bigger DBs increase cache miss penalties and vacuum/maintenance pain.
Decision: If the DB is growing rapidly and you’re on a single node, plan for MySQL earlier—especially if you need online maintenance.
Task 6: Check for WAL file bloat (checkpoint pressure)
cr0x@server:~$ ls -lh /var/lib/app/app.db*
-rw-r----- 1 app app 1.0G Dec 30 10:12 /var/lib/app/app.db
-rw-r----- 1 app app 6.2G Dec 30 10:12 /var/lib/app/app.db-wal
-rw-r----- 1 app app 32K Dec 30 10:12 /var/lib/app/app.db-shm
What it means: A huge WAL file usually means checkpointing isn’t keeping up (long readers, misconfigured checkpoints, or write bursts).
Decision: Investigate long-lived read transactions. If you can’t control them (multiple services, background jobs), migrate—MySQL handles this class of issue more gracefully.
Task 7: Measure disk latency (IO is the silent killer)
cr0x@server:~$ iostat -x 1 3
Linux 6.1.0 (server) 12/30/2025 _x86_64_ (8 CPU)
avg-cpu: %user %nice %system %iowait %steal %idle
12.00 0.00 6.00 18.00 0.00 64.00
Device r/s w/s rkB/s wkB/s await svctm %util
nvme0n1 120.0 300.0 6400.0 22000.0 28.5 1.1 95.0
What it means: await ~28ms at 95% utilization is rough. SQLite writes can be fsync-heavy depending on pragmas and transaction patterns.
Decision: If storage is slow, fixing disk may solve both SQLite and MySQL problems. If you already have decent storage and still see lock contention, migration remains likely.
Task 8: Find open file handles and processes touching the DB (multi-writer reality check)
cr0x@server:~$ lsof /var/lib/app/app.db | head
app 2314 app 12u REG 253,0 1073741824 12345 /var/lib/app/app.db
app 2314 app 13u REG 253,0 6657199308 12346 /var/lib/app/app.db-wal
worker 2551 app 10u REG 253,0 1073741824 12345 /var/lib/app/app.db
What it means: Multiple processes are using the same DB file. That’s fine on one machine if coordinated, but it increases the chance of lock contention and long transactions.
Decision: If your architecture naturally wants many writers, stop trying to make SQLite behave like a server DB.
Task 9: Check filesystem type and mount options (durability assumptions)
cr0x@server:~$ findmnt -no FSTYPE,OPTIONS /var/lib/app
ext4 rw,relatime,errors=remount-ro
What it means: Local ext4 is usually sane. If you see NFS/CIFS/FUSE-backed mounts, durability and locking semantics can get exciting in the wrong way.
Decision: If the DB file lives on network storage and availability matters, migrate. Don’t turn your database into a distributed filesystem experiment.
Task 10: Observe lock waits and timeouts in the app logs (the human-visible symptom)
cr0x@server:~$ grep -E "database is locked|SQLITE_BUSY|timeout" /var/log/app/app.log | tail -n 5
2025-12-30T10:11:58Z ERROR db write failed: SQLITE_BUSY: database is locked
2025-12-30T10:11:59Z WARN retrying transaction after SQLITE_BUSY
2025-12-30T10:12:02Z ERROR request_id=9f2d api=/events POST latency_ms=4210 sqlite_busy_retries=5
What it means: Busy retries are inflating latency. You’re not just slow; you’re unstable under burst.
Decision: If lock retries correlate with traffic peaks and writes are core to the product, plan migration. If it’s a rare admin operation, you can isolate it instead.
Task 11: Run a quick write-concurrency microtest (does it collapse under two writers?)
cr0x@server:~$ bash -lc 'for i in {1..2}; do (time sqlite3 /var/lib/app/app.db "BEGIN; INSERT INTO events(user_id,created_at,payload) VALUES(42,datetime(\"now\"),\"x\"); COMMIT;" ) & done; wait'
real 0m0.012s
user 0m0.003s
sys 0m0.002s
real 0m1.104s
user 0m0.004s
sys 0m0.003s
What it means: One writer finishes quickly; the other waits around a full second (or more under load). That waiting becomes user-facing latency.
Decision: If your production profile expects concurrent writes, you migrate. If writes are rare and you can batch them, you may stay on SQLite.
Task 12: Validate SQLite integrity (are you already in trouble?)
cr0x@server:~$ sqlite3 /var/lib/app/app.db "PRAGMA integrity_check;"
ok
What it means: Good. If it returns anything else, you have corruption and should treat it as a production incident.
Decision: Corruption risk is a forcing function: prioritize migration and fix storage/transaction handling immediately.
Task 13: Compare with MySQL baseline (can it absorb concurrency?)
cr0x@server:~$ mysql -e "SHOW GLOBAL STATUS LIKE 'Threads_running'; SHOW GLOBAL STATUS LIKE 'Innodb_row_lock_time';"
+-----------------+-------+
| Variable_name | Value |
+-----------------+-------+
| Threads_running | 18 |
+-----------------+-------+
+------------------------+-------+
| Variable_name | Value |
+------------------------+-------+
| Innodb_row_lock_time | 1240 |
+------------------------+-------+
What it means: MySQL can have many active threads. Row lock time gives you a window into contention (not perfect, but useful).
Decision: If MySQL shows manageable row lock time while SQLite is timing out, that’s your migration justification in one screenshot.
Task 14: Check MySQL durability posture (don’t migrate into new risk)
cr0x@server:~$ mysql -e "SHOW VARIABLES LIKE 'innodb_flush_log_at_trx_commit'; SHOW VARIABLES LIKE 'sync_binlog';"
+------------------------------+-------+
| Variable_name | Value |
+------------------------------+-------+
| innodb_flush_log_at_trx_commit | 1 |
+------------------------------+-------+
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| sync_binlog | 1 |
+---------------+-------+
What it means: These settings are the “I care about durability” defaults for many production systems.
Decision: If you migrate to MySQL but set these to relaxed values without understanding the failure semantics, you didn’t upgrade—you just changed the style of data loss.
Three corporate mini-stories (and the lesson you want)
Mini-story 1: The incident caused by a wrong assumption
A small platform team shipped a service that stored customer workflow state in SQLite. It was elegant: one binary, one DB file, easy backups.
They even used WAL mode and had a “busy timeout,” so they felt prepared.
Then the company added a second instance behind a load balancer for redundancy. The DB file moved to a shared mount so both instances could “see the same state.”
It worked for a week, which is the most dangerous duration in engineering because it teaches the wrong lesson.
Under peak usage, both instances attempted writes. Lock waits started piling up. The retry logic dutifully retried, which increased the write rate,
which increased lock contention. Users saw timeouts, and on-call saw CPU mostly idle. “But it’s not compute,” they said. “It’s fine.”
The real culprit was the assumption: “If both processes can read the file, they can safely coordinate writes over a network filesystem.”
That assumption is a trap. SQLite expects certain locking and fsync semantics. Networked storage can provide them sometimes, until it doesn’t.
The fix was not heroic: they deployed MySQL, pointed both instances at it, and removed the shared-file layer entirely.
The first incident after that was boring. Boring is the correct outcome.
Mini-story 2: The optimization that backfired
Another team had a write-heavy ingestion service with SQLite. They were hitting latency spikes. Someone found a blog post about
performance pragmas and pushed a change: set PRAGMA synchronous=OFF and PRAGMA journal_mode=MEMORY.
The graphs looked amazing. Lower disk IO, higher throughput, fewer timeouts. The team celebrated quietly because they’d learned not to celebrate loudly.
Two months later, a host reboot happened mid-write during a routine kernel patch. Not dramatic. Normal ops.
After reboot, the DB started throwing integrity errors. Some recent data was missing, and some foreign key relationships were inconsistent.
The team had backups, but restores meant losing legitimate recent writes. They ended up reconstructing data from upstream logs and partial exports.
The backfire wasn’t that durability settings exist; it was that the system’s true requirement was “don’t lose accepted writes.”
If your system requires that, performance optimizations that weaken durability are not optimizations—they’re loans with predatory interest.
They migrated to MySQL with proper redo logging and binlog sync settings. Performance was fine, and failures became recoverable instead of existential.
Mini-story 3: The boring but correct practice that saved the day
A third org used SQLite for a desktop-ish agent that ran in customer environments. Their architecture made sense: one agent, one local DB,
low concurrency. They still treated it like production storage because customers don’t care that it’s “just an agent.”
They implemented three boring practices: transactional writes with clear boundaries, periodic integrity checks, and a backup routine that copied the DB
using SQLite’s online backup API rather than raw file copying during active writes.
One customer had a flaky disk. The agent started logging IO errors. Because integrity checks were already in place, the agent detected corruption early,
quarantined the DB, restored from the last known-good backup, and replayed a small buffer of recent events from a local queue.
The customer never filed a ticket. Internally, the team saw the alert, opened an incident, and closed it with a shrug.
That shrug is the sound of good operational design.
Lesson: you don’t migrate just because you can. You migrate because your operational requirements changed. Until then, do the boring correctness work.
Joke #2: The easiest way to make SQLite “highly available” is to print the database file and keep it in two different offices.
Common mistakes: symptom → root cause → fix
1) Symptom: random timeouts during traffic spikes
Root cause: write contention (one-writer limitation), plus retries amplifying load.
Fix: reduce write frequency (batching), shorten transactions, use WAL, add busy timeout with backoff. If multiple writers are fundamental: migrate to MySQL.
2) Symptom: “database is locked” errors appear after adding a background worker
Root cause: a new process introduced a second write source; transactions overlap; long readers prevent WAL checkpointing.
Fix: ensure writers are serialized (single writer queue) or move write workload to MySQL. Audit background jobs for long-running read transactions.
3) Symptom: WAL file grows without bound
Root cause: checkpoint cannot complete because long-lived readers keep old pages alive; or checkpoint settings are too lax.
Fix: eliminate long read transactions; explicitly checkpoint during low-traffic windows; consider migrating if many services read concurrently.
4) Symptom: after crash/reboot, recent data is missing
Root cause: durability settings weakened (synchronous=OFF, improper filesystem, unsafe mount options) or writes not wrapped in transactions.
Fix: restore durability pragmas, use transactions, move DB to reliable local storage. If you need strong durability guarantees at scale: MySQL with correct flush/binlog settings.
5) Symptom: query is fast in dev, slow in prod
Root cause: dev dataset small; prod has skew; missing index; query plan changes; heavy LIKE/ORDER BY without index support.
Fix: run EXPLAIN QUERY PLAN on prod-like data; add composite indexes; rewrite queries. Don’t blame SQLite for an unindexed sort.
6) Symptom: app CPU spikes when a report runs
Root cause: SQLite runs inside the app process; heavy queries steal CPU from request handling.
Fix: isolate reporting workload, run it off a replica (MySQL), or move analytics to a separate store. At minimum, run reports in a separate process with resource limits.
7) Symptom: “it worked until we containerized it”
Root cause: DB file placed on overlay filesystem or network volume; fsync/locking behavior changed; IO got slower.
Fix: put SQLite on local persistent volume with known semantics, or stop using SQLite in a multi-container write scenario and migrate to MySQL.
Checklists / step-by-step plan: migrate without heroics
Decision checklist: should you migrate this quarter?
- Do you have more than one writer in production (multiple processes, workers, cron, app instances)?
- Is tail latency (p95/p99) driven by lock waits or retries?
- Do you require high availability (continue writes through node failure) rather than “we have backups”?
- Do you need online operations: backups without downtime, schema changes with minimal impact, query killing, observability?
- Are you putting the SQLite DB on shared or networked storage?
If you answered “yes” to two or more, plan the migration. If you answered “yes” to shared storage or HA requirements, stop debating and schedule it.
Migration plan: the sane sequence
- Define correctness requirements: durability (what can be lost?), consistency (read-your-writes?), acceptable downtime.
- Inventory schema differences: data types, autoincrement behavior, date/time storage, constraints, default values.
- Pick migration strategy:
- Big cutover: stop writes, export/import, switch. Simple, needs downtime window.
- Dual-write: write to both, read from SQLite, then flip reads, then stop SQLite. Harder, less downtime.
- Change-data-capture-like: usually overkill for SQLite unless you already have an event log.
- Stand up MySQL with production defaults: backups, monitoring, slow query log, sane durability settings.
- Backfill data from SQLite into MySQL and validate counts and checksums.
- Run canary reads: compare query results between SQLite and MySQL for critical paths.
- Flip traffic gradually if possible; if not, do a clean cutover with a rollback plan.
- Keep SQLite read-only for a defined period as a safety net, then archive.
Operational checklist for MySQL (so you don’t “upgrade” into chaos)
- Backups tested by restore (not just “backup jobs are green”).
- Monitoring for replication lag (if using replicas), disk space, buffer pool hit rate, slow queries, connection saturation.
- Connection pooling in the app. Don’t open 2,000 MySQL connections because you discovered threads.
- Schema migration plan (online where possible, or scheduled windows).
- Capacity plan for storage growth (InnoDB grows and likes free space for maintenance).
FAQ
1) Is SQLite “not for production”?
It’s absolutely for production—when “production” means embedded, single-node, and low write concurrency. It’s in production on billions of devices.
The wrong move is using it as a shared multi-writer database for a horizontally scaled service.
2) Does WAL mode make SQLite handle many writers?
WAL helps readers not block writers and vice versa, but it doesn’t turn SQLite into a many-writer system. You still have one writer at a time.
WAL is a concurrency improvement, not a distributed transaction coordinator.
3) What’s the biggest practical sign I should migrate?
Persistent write contention under normal load—timeouts, lock errors, retries inflating latency. If your product is write-driven, that’s the line.
4) Can I put SQLite on NFS if I’m careful?
You can, and some people do, and some of them get paged later. Filesystem locking and durability semantics across network storage are a reliability tax.
If you need multiple machines, use a database server.
5) Is MySQL always faster than SQLite?
No. SQLite can be faster for simple local reads and small writes because there’s no network hop and minimal overhead.
MySQL tends to win when concurrency, isolation, buffering, and operational controls matter.
6) What about using SQLite as a cache and MySQL as source of truth?
That can work if you treat SQLite as disposable and rebuildable. The moment SQLite becomes “the only place” something lives, it stops being a cache and starts being your database.
7) How do I avoid migrating too early?
Prove the bottleneck. If your issue is missing indexes or sloppy transaction boundaries, fix those first. Migration is justified when the architecture limits you, not when the schema needs love.
8) What’s the simplest safe backup approach for SQLite?
Use SQLite’s online backup API (via your language bindings or the CLI’s backup features) rather than copying the file while writes are ongoing.
Validate backups by restoring and running PRAGMA integrity_check;.
9) If I migrate to MySQL, what new failure mode will bite me first?
Connection storms and poorly configured pooling. SQLite hid that because it’s in-process. MySQL will happily accept your load test until it runs out of threads or memory.
10) Should I use MySQL or something else (Postgres, etc.)?
If your choice is specifically SQLite vs MySQL, choose MySQL when you need server-grade concurrency, replication, and operational tooling.
If you’re doing a broader evaluation, decide based on team skills and operational constraints. The core point remains: server DB when you need server properties.
Conclusion: practical next steps
SQLite doesn’t “fail.” Teams ask it to do a job it was never hired for, then blame it for having boundaries.
MySQL isn’t “better” in the abstract. It’s better when you need concurrent writes, HA patterns, observability, and operational controls that don’t require reinventing a database team.
If you’re unsure, don’t argue. Measure. Run the fast diagnosis playbook, execute the tasks above, and look for the signature:
lock waits, tail latency inflation, and operational risk around durability and shared storage.
- If the issue is query design: add indexes, fix transactions, re-test.
- If the issue is storage: fix IO first; bad disks make every database look incompetent.
- If the issue is concurrency and HA requirements: schedule the MySQL migration and treat it like an infrastructure project, not a refactor.
The best time to migrate is before you’re debugging a locked database at 3 a.m. while trying to remember whether synchronous=OFF was “just temporary.”