Developer writing SQL migration code on a laptop

SQLite in Production 2026: The Complete Guide

August 13, 2026 · 18 min read · By Thomas A. Anderson

Your SQLite database locks on every write. When a second user tries to update a profile while the first user checks out, one of them waits. When a background job sends emails while the API serves requests, everything queues. The single-writer architecture that carried your prototype is now the reason your p99 latency keeps climbing and the error log fills with sqlite3.OperationalError: database is locked. This is the moment most teams decide to migrate, and the migration itself becomes a second, quieter emergency.

This post is the hub for a five-part series on running SQLite in production in 2026. It frames the full arc, explains who the series is for, and points you to the parts that matter for your situation. Here you will learn when SQLite genuinely fits a production workload, when PostgreSQL is the better answer, and how to get from one to the other without losing data or confidence. The detailed playbooks live in the later parts, but the decision framework and the migration skeleton are right here.

Why Teams Move in 2026

The trigger is almost never “SQLite is slow.” On a single server with local file access, SQLite is fast, sometimes faster than PostgreSQL for simple lookups because there is no network hop. The triggers are structural, and they arrive together once an application grows past a fairly predictable point:

Schema Translation Is Not Copy-Paste
  • Multiple application instances. Deploying a second web server breaks SQLite, which does not work over network filesystems. The SQLite documentation warns that “file locking logic is buggy in many network filesystem implementations on both Unix and Windows.”
  • Concurrent writers. SQLite serializes writes with a database-level lock. PostgreSQL uses Multi-Version Concurrency Control, which lets many writers proceed in parallel while readers see a consistent snapshot.
  • Dataset size. The Render migration guide notes that SQLite performs well up to roughly 10 GB, and beyond that you notice degraded performance on complex queries, while PostgreSQL handles terabytes.
  • Feature needs. Full-text search, JSON querying, row-level security, and custom extensions are areas where PostgreSQL is far ahead.

A widely repeated operational rule from the Render migration guide: if you see database is locked in your logs more than once a week, start planning the migration now. Each week you wait adds more data to transfer and more application code that assumes SQLite behavior. The worst time to design a migration is during an outage.

PostgreSQL is the natural destination in 2026. The independent State of PostgreSQL Performance assessment calls it the default choice for new projects. The question is not whether to use PostgreSQL, but whether to migrate well.

Confirm You Actually Need to Move

The honest first question is whether you need to migrate at all. SQLite in WAL mode handles surprising write loads and essentially unlimited concurrent reads. The reflex that “SQLite is only for testing” is a decade out of date, and some teams move to PostgreSQL and then regret the new operational burden of running a server.

Staying on SQLite is legitimate if:

  • You run on a single server with local file access.
  • Your read-write ratio is heavily read-biased, above roughly 90% reads.
  • Your dataset fits comfortably under the practical size ceiling.
  • You can tolerate brief downtime for schema migrations.

Migrate to PostgreSQL if any of these apply:

  • You need multiple application servers reading and writing the same data.
  • You need thousands of concurrent write transactions from different processes, such as trading platforms or real-time analytics ingest.
  • You need roles, row-level security, or connection pooling across services.
  • Your schema will evolve frequently and you cannot afford rebuild-and-copy table migrations.

The rest of this series, especially Part 1 on understanding SQLite and Part 3 on the head-to-head database comparison, explores this decision in depth. For a working decision tree with benchmark numbers and cost analysis, read our earlier analysis of SQLite in production benchmarks and limits.

The Real Work Is the Architecture Change

Here is the framing most migration guides miss. As the Mako migration guide puts it, “SQLite-to-PostgreSQL is usually not a database migration so much as an architecture change: from an embedded, in-process file to a client/server system with network access, roles, and concurrent writers. The data transfer is the easy part, pgloader does it in one line. The work is in what SQLite’s flexibility let you get away with.”

The application change is where most of the effort sits. Several things change at once:

# Before: SQLite connects to a local file, no server, no network
import sqlite3
conn = sqlite3.connect('app.db')
conn.execute("PRAGMA journal_mode=WAL;")
conn.execute("PRAGMA busy_timeout=5000;")
cursor = conn.execute("SELECT * FROM orders WHERE customer_id=?", (4217,))
print(cursor.fetchall())

# After: PostgreSQL needs a connection string and a pool
import psycopg2
from psycopg2 import pool
# Note: production use adds retry logic, deadlock handling, and SSL.
pg_pool = psycopg2.pool.SimpleConnectionPool(
 1, 10, dsn="postgresql://user:pass@db-host:5432/appdb")
conn = pg_pool.getconn()
cursor = conn.execute("SELECT * FROM orders WHERE customer_id=%s", (4217,))
print(cursor.fetchall())
pg_pool.putconn(conn)
  1. The connection model changes. A file path becomes a connection string, and “open file” becomes a pooled network connection. The very first query in a session carries connection-setup overhead that pooling is designed to hide; without a pool, every new connection pays that cost.
  2. Concurrency semantics change. SQLite serializes writes and signals contention with SQLITE_BUSY. PostgreSQL gives you genuine parallel writes but introduces new failure modes: deadlocks and serialization errors under the SERIALIZABLE isolation level. Your retry logic must now handle those.
  3. Query dialect shifts. The good news is the move is far less painful than MySQL to PostgreSQL. Both support Common Table Expressions, window functions, RETURNING, and ON CONFLICT upserts with near-identical syntax. The gaps: SQLite’s flexible GROUP BY (bare columns that are not aggregated) becomes an error, strftime() becomes to_char(), and dynamic typing tricks like WHERE id = '42' start raising type errors.
  4. Case-insensitive LIKE behaves differently. SQLite’s LIKE is case-insensitive for ASCII by default; PostgreSQL’s is case-sensitive. Queries that silently relied on it return fewer rows. Use ILIKE or the citext extension.

An ORM such as Django’s, Ruby on Rails ActiveRecord, or Prisma absorbs many of these differences, which is why the framework-specific path is often the least error-prone for teams already using one. But raw SQL and SQLite-specific features are exactly where hidden breakage lives, so those need explicit testing.

Schema Translation Is Not Copy-Paste

The single biggest trap is SQLite’s type affinity. PostgreSQL columns have strict types. SQLite columns have affinities, which are suggestions. The string 'twenty-five' can sit in an INTEGER column, and numbers can hide in date columns. Every such legacy value fails at load time when PostgreSQL enforces its types.

Audit before migrating. The typeof() function tells you what actually lives in each column:

-- What types actually live in this column?
SELECT typeof(user_id), COUNT(*) FROM orders GROUP BY typeof(user_id);
-- Expect: integer | 48213
-- Fear: integer | 48211
-- text | 2

-- Two stray text values in an integer column will fail the load.
-- Fix them in SQLite first, where it is a one-liner:
UPDATE orders SET user_id = CAST(user_id AS INTEGER)
WHERE typeof(user_id) = 'text';

The type mapping table below shows how SQLite storage classes map to PostgreSQL types, based on the mapping tables in the Mako guide and the Render guide.

SQLite (declared) PostgreSQL Notes
INTEGER bigint SQLite integers are up to 8 bytes; do not assume int4 fits
REAL double precision Straightforward mapping
TEXT text Straightforward mapping
BLOB bytea Straightforward mapping
BOOLEAN (declared) boolean SQLite stores as 0/1 integers; needs an explicit cast rule
Datetimes timestamptz / timestamp SQLite has no native datetime; format audit required
INTEGER PRIMARY KEY AUTOINCREMENT bigserial or identity Sequences must be reset after any manual load
JSON stored as TEXT jsonb pgloader converts valid JSON automatically

Datetimes deserve special attention. SQLite has no datetime type, so every project invented its own convention, and many picked several. You may find ISO-8601 strings (2024-03-15 10:30:00), Unix epoch seconds, epoch milliseconds (JavaScript did this), and Julian day numbers, sometimes in the same column. ISO strings load straight into timestamp. Epoch integers need a to_timestamp() conversion after load. Epoch milliseconds need dividing by 1000 first; loading them raw puts your data far in the future, which makes the validation step at least easy to spot.

The pgloader Path: Data Transfer in One Command

pgloader is the workhorse for most SQLite-to-PostgreSQL migrations. It reads the SQLite file directly, no dump step required, discovers the schema, creates the equivalent schema in PostgreSQL, converts types using default cast rules, loads data over the COPY protocol, creates indexes, and resets sequences. The pgloader documentation confirms it “considers SQLite as a database source and implements schema discovery from SQLite catalogs” and performs “a standard data type conversion from SQLite to PostgreSQL.”

The command is simple. The dev.to runthrough shows the containerized form:

# Install pgloader (Ubuntu/Debian or macOS)
sudo apt-get install pgloader # or: brew install pgloader

# One-shot migration from a local SQLite file
createdb appdb
pgloader ./app.sqlite postgresql:///appdb

# In Docker, against a remote or local PostgreSQL host
docker run --rm -it \
 -v /path/to/sqlite:/data/app.sqlite \
 dimitri/pgloader:latest \
 pgloader --with "DATA ONLY" --verbose \
 sqlite:///data/app.sqlite \
 postgresql://user:pass@db-host:5432/appdb

Use --with "DATA ONLY" when you have already created the tables in PostgreSQL, typically because your ORM or a migration framework built them. Use host.docker.internal to reach a PostgreSQL instance running on your local machine from inside Docker. Make sure the SQLite path inside the container matches the path in the pgloader command.

For finer control, use a load file, as shown in the Mako guide:

-- migration.load
LOAD DATABASE
 FROM sqlite:///path/to/app.sqlite
 INTO postgresql://app@localhost/appdb

WITH include drop, create tables, create indexes, reset sequences

CAST column users.is_active to boolean using tinyint-to-boolean,
 column events.created_at to timestamptz using unix-timestamp-to-timestamptz;

-- Set memory for sorting and index creation
SET work_mem to '256MB',
 maintenance_work_mem to '512MB'

Two common issues surface during the load. First, if you loaded data any way other than pgloader’s reset sequences, your identity columns hand out key 1 to the next insert and collide immediately. Fix it with the setval command from the Mako guide:

SELECT setval(pg_get_serial_sequence('users', 'id'),
 (SELECT MAX(id) FROM users));

Second, the schema may contain design flaws that PostgreSQL rejects. The Database School walkthrough shows a common example: pgloader attempted to add FOREIGN KEY constraints that failed with “there is no unique constraint matching given keys for referenced table”. The migration completed and loaded all rows, but three foreign keys failed. This is a signal to review referential integrity manually before cutover. In a strict schema where foreign keys were never enforced in SQLite, you may be migrating orphaned references that PostgreSQL will reject on insert.

Zero-Downtime Options for Live Systems

For most applications, a maintenance window is the right call. The Render guide recommends announcing a maintenance window sized to your database, stopping the app to prevent new writes, running pgloader, validating, flipping the connection string, and starting the app. For databases under 10 GB, the maintenance window migration typically completes in under an hour, which most applications can tolerate during off-peak hours.

For applications that cannot tolerate downtime, a dual-write migration works, at a cost. You write to both SQLite (primary) and PostgreSQL (secondary) simultaneously, continue reading from SQLite, validate for a few days, switch reads to PostgreSQL, then stop writing to SQLite after a week of stable operation.

Be careful with dual-write. It increases SQLite’s lock contention, which is the problem you are trying to solve. The Render guide warns to expect 20 to 40% slower writes during the migration window and to build careful error handling to prevent data divergence. PostgreSQL writes should never block the user: wrap the secondary write in a best-effort function that logs failures and alerts the operations team for manual reconciliation.

# A simplified dual-write helper (production adds monitoring and retries)
def create_user(email, name):
 # Write to primary (SQLite) synchronously
 sqlite_db.execute(
 "INSERT INTO users (email, name) VALUES (?, ?)", (email, name))
 # Best-effort secondary write to PostgreSQL, never blocks a user
 try:
 postgres_db.execute(
 "INSERT INTO users (email, name) VALUES (%s, %s)", (email, name))
 except Exception as e:
 logger.error(f"Postgres sync failed: {e}") # alert ops for reconciliation
 return sqlite_user

There is a middle path between full downtime and dual-write: the expand-contract pattern. First expand the application to write to both systems, then migrate the backfill, then contract back to the single new system. It spreads risk across phases instead of one big cutover, which is a good fit for teams that cannot afford a maintenance window but are wary of the lock contention that heavy dual-write adds.

Validate Before You Flip the Switch

Row counts alone are not enough. The Mako guide recommends aggregate checksums on both sides. Run identical queries against the SQLite file and the new PostgreSQL database and diff the output:

-- Run against BOTH databases and compare
SELECT COUNT(*), SUM(amount_cents), MIN(created_at), MAX(created_at)
FROM orders;

This catches the datetime disasters instantly. If epoch-millis were loaded as epoch-seconds, the min and max values are wrong by decades. The typeof() audit column deserves special attention, as does any column PostgreSQL converted during load.

Check referential integrity explicitly. SQLite allowed orphaned foreign keys because enforcement was historically optional. PostgreSQL rejects them on insert. The Render guide offers this query:

-- Find orphaned foreign keys (should return no rows)
SELECT o.id FROM orders o
LEFT JOIN users u ON o.user_id = u.id
WHERE u.id IS NULL;

Then run your application’s most common queries against PostgreSQL: authentication, order creation, search, reporting. Compare results against SQLite. Differences indicate a type conversion issue. Finally, run EXPLAIN ANALYZE on your slowest queries and check for missing indexes, the most consequential performance oversight in PostgreSQL. A filtered column without an index can turn a plan from a fast index scan into a full table scan, which is the difference between a query that returns in milliseconds and one that stalls a connection pool.

Migration Tooling Compared

pgloader is the default, but it is not the only option. Three common approaches stand out, and the right one depends on your schema size, whether you already use an ORM, and whether you can tolerate downtime.

Approach Best fit Schema Downtime Risk
pgloader Large or complex schemas, one-shot load Auto-created from SQLite catalogs Maintenance window typical Type mismatch and sequence issues
ORM migration framework (Django, ActiveRecord, Prisma) Teams already on an ORM Generated from your existing models Can support dual-write Raw SQL and SQLite-specific syntax must be tested
Manual CREATE TABLE + script Small simple schemas, or redesign from scratch Hand-written Full downtime Most work, highest human error

For teams on ORMs, the framework path is often the least error-prone. The ClawDUX case study shows the Prisma approach: the data model stays the same and only the provider changes from sqlite to postgresql. Prisma then handles bigint, boolean, and datetime conversions transparently. The trade-off is that a framework-generated load still needs a data transfer step (often a script or pgloader with DATA ONLY), and you still need to test every raw query.

One pragmatic hybrid that several production teams use: let your preferred framework create the schema and indexes so it matches exactly what your application expects, then use pgloader with --with "DATA ONLY" to move the rows, as the Twilio engineering writeup describes. This keeps the target schema under your control while relying on pgloader’s fast COPY-based transfer for the data.

Developer writing SQL migration code on a laptop
Most of the migration effort is in the application code changes, not the data transfer.

What PostgreSQL Claims vs What You Should Verify

The marketing around PostgreSQL is strong, and much of it is fair. But an honest migration plan treats performance claims as unverified until your own measurements confirm them. Postgres is mature and popular, yet it brings real operational demands that SQLite never had.

The DCAC review of PostgreSQL operational challenges names two common scaling drags: no execution plan cache and an I/O-intensive vacuum process. These matter mainly at higher throughput workloads, so a small migrated application may never feel them. Still, a first-time PostgreSQL operator faces real costs: a server to monitor, connections to pool, users and roles to manage, and replication and backup tooling that SQLite teams were not running.

The independent State of PostgreSQL Performance assessment is candid that the engine is not the bottleneck in most deployments; the queries sent to it are. The same problems repeat across hundreds of inspected environments: missing indexes, N+1 query patterns generated by ORMs, and autovacuum configurations untouched since first provisioning. A naive migration that preserves your old query habits will produce a slow PostgreSQL database, and the failure is a planning problem, not an engine problem.

In practice, write throughput flips the most. Complex joins can get meaningfully faster thanks to the query planner. So the honest summary: you trade away a little single-connection latency to gain the ability to actually scale writes, and you inherit a server’s worth of operational work.

Plan your upgrade path for that operation overhead before you flip the switch. Decide who owns backups, connection pooling, monitoring, and replication. Teams that skip this step find themselves running a heavier database with no plan, which is exactly the outcome that makes some people say the migration was a mistake.

The Series Roadmap

This post is the hub. Each planned part of the series goes deeper on one slice of the SQLite in production question, and you can start anywhere.

  • Part 1, “Understanding SQLite: Architecture, Use Cases, and Limitations.” The fundamentals: how SQLite works, why it is chosen for embedded and mobile applications, and where it struggles with high concurrency. Read this if you are deciding whether SQLite fits at all.
  • Part 2, “SQLite Benchmarks and Limits in Real-World Workloads.” Benchmark results comparing SQLite with PostgreSQL under various workloads, with concrete numbers for read and write throughput, latency, and scalability, plus the limits you actually hit in production: database size, connection count, and write throughput.
  • Part 3, “SQLite vs PostgreSQL for Production: Key Differences.” A head-to-head on concurrency, scalability, and feature set, with guidance on when each is the better production choice.
  • Part 4, “Performance Tuning SQLite for High-Throughput Apps.” A step-by-step tuning guide: journal modes, cache sizes, transaction management, and how to measure the improvements, plus hardware considerations.
  • Part 5, “Common Pitfalls and Best Practices for SQLite in Production.” The failure modes and best practices: locking issues, backup challenges, data corruption risk, schema design, and transaction handling.

If you are weighing a migration soon, Part 3 (the comparison) and this hub’s decision framework are the right starting points. If you have already decided to migrate, the pgloader path above and the validation checklist are what you need today. Start by backing up your SQLite database, then run a test migration against a copy of the real data on a staging environment before you touch production.

Key Takeaways:

  • Most teams migrate because of structural limits, not raw speed: multiple app instances, concurrent writers, or a database that outgrows its file.
  • SQLite-to-PostgreSQL is an architecture change, from embedded file to client-server system, and the application code changes are where most effort goes.
  • Audit your data with typeof() before migrating; SQLite type affinity hides decades of bad values that PostgreSQL strict types will reject.
  • pgloader reads the SQLite file directly, creates the schema, converts types, loads data, builds indexes, and resets sequences in one command.
  • Choose a maintenance window for most apps, dual-write or expand-contract for downtime-sensitive ones, and validate with checksums and referential integrity checks, not just row counts.
  • PostgreSQL brings a server’s worth of operational work; teams that skip planning for backups, pooling, and monitoring find the migration feels like a step backward.

Sources and References

Sources cited while researching and writing this article:

Series outline

Part 1 · Read now

Deep Dive into SQLite Architecture

Explore SQLite’s architecture, concurrency, performance tuning, and production considerations to optimize its use in real-world applications.

Read Part 1 →

Part 2 · Read now

SQLite in Production 2026: Real Benchmarks, Limits, and When to Migrate to Postgres

A practical guide to running SQLite in production in 2026, with real benchmarks, concurrency limits, operational playbook, and a decision framework for when to migrate to Postgres.

Read Part 2 →

Part 3 · Read now

SQLite vs PostgreSQL for Production: Key Differences

This part explores the key differences between SQLite and PostgreSQL, focusing on features relevant to production use, such as concurrency, scalability, and feature set. It compares their architectures, strengths, and typical deployment environments. The goal is to help readers understand when SQLite is suitable and when PostgreSQL may be a better choice.

Read Part 3 →

Part 4 · Coming soon

Performance Tuning SQLite for High-Throughput Apps

This part provides a step-by-step guide to optimizing SQLite performance for high-throughput applications. It covers practical tuning tips such as journal modes, cache sizes, and transaction management. It also discusses hardware considerations and how to measure performance improvements.

Part 5 · Read now

Common Pitfalls and Best Practices for SQLite in Production

This part identifies common pitfalls encountered when deploying SQLite in production, such as locking issues, backup challenges, and data corruption risks. It offers best practices to avoid these issues, including proper schema design, transaction handling, and backup strategies.

Read Part 5 →

Thomas A. Anderson

Mass-produced in late 2022, upgraded frequently. Has opinions about Kubernetes that he formed in roughly 0.3 seconds. Occasionally flops, but don't we all? The One with AI can dodge the bullets easily; it's like one ring to rule them all... sort of...