A SQLite to PostgreSQL migration should preserve identifiers, validate source types, import into an explicit target schema, repair generated-ID sequences, and verify data before switching writes. Rehearse the procedure against copies, and plan how new writes will be handled during cutover.
SQLite remains an excellent database for prototypes and small tools.
The problem appears when a project moves from local experiment to deployed application and persistence starts to matter.
This does not mean SQLite is bad. It means the operational requirements changed.
SQLite is often the right place to start
For an early application, SQLite provides several advantages:
- almost zero setup;
- no database server;
- easy local development;
- simple backups;
- strong SQL support;
- fast iteration.
For a tool used by one process on one machine, it can be all that is needed.
The decision to migrate should be driven by requirements, not by the assumption that every application needs a large database.
Deployment changes the problem
Some hosting environments use ephemeral filesystems.
A local SQLite file may disappear when:
- the service is redeployed;
- the container restarts;
- the instance is replaced;
- the platform moves the workload.
This is the moment many small applications discover that “the database worked in development” is different from “the data is durable in production.”
Before deploying SQLite, verify how the hosting platform handles persistent disk.
Concurrency may become the next limit
SQLite supports concurrent reads well, but writes are serialized.
That is acceptable for many workloads.
Problems can appear when the application starts handling:
- multiple simultaneous users;
- background jobs;
- repeated API writes;
- admin operations;
- scheduled processing;
- several application instances.
PostgreSQL is designed for this type of multi-client workload.
Look for operational requirements
A migration becomes more attractive when the application needs:
- durable managed storage;
- multiple application instances;
- backups and point-in-time recovery;
- stronger concurrent write behavior;
- database roles;
- network access from several services;
- monitoring;
- more advanced indexing or extensions.
These are operational capabilities, not just SQL features.
Prepare the schema before moving data
A migration is a good opportunity to remove prototype shortcuts.
Review:
- primary keys;
- foreign keys;
- nullability;
- unique constraints;
- timestamps;
- indexes;
- enum-like fields;
- default values.
SQLite’s permissive typing can hide data that a stricter PostgreSQL schema will reject.
Running validation before import makes the migration much easier.
SQLite uses dynamic typing unless a table is declared with supported strict typing. PostgreSQL will reject values that do not fit the target types. Review booleans, dates, money, binary data, JSON, case-sensitive identifiers, and collation behavior explicitly. See SQLite datatype rules.
A small, explicit migration example
For an illustrative accounts(id, name, active) table, export only validated rows. Save this as export_accounts.py beside a copy of source.sqlite. The script expects positive integer IDs, nonempty text names, and integer booleans; adapt those rules to the real schema.
import csv
import sqlite3
with sqlite3.connect("file:source.sqlite?mode=ro", uri=True) as db:
db.execute("BEGIN")
rows = db.execute("SELECT id, name, active FROM accounts ORDER BY id").fetchall()
seen = set()
for account_id, name, active in rows:
if type(account_id) is not int or account_id <= 0 or account_id in seen:
raise ValueError("invalid or duplicate account ID")
if not isinstance(name, str) or not name.strip():
raise ValueError("account name is required")
if type(active) is not int or active not in (0, 1):
raise ValueError("active must be integer 0 or 1")
seen.add(account_id)
with open("accounts.csv", "w", encoding="utf-8", newline="") as output:
writer = csv.writer(output)
writer.writerow(["id", "name", "active"])
writer.writerows((i, n, "true" if a else "false") for i, n, a in rows)
This small example keeps all rows in memory so validation finishes before writing the output. Large migrations need chunked staging, stable snapshot semantics, and a manifest of validated outputs. A read transaction protects this single-table export from changes observed midway through the read, but it does not capture later writes or coordinate exports from separate connections.
Create the target schema in a disposable PostgreSQL database first:
CREATE TABLE accounts (
id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL,
active boolean NOT NULL
);
Then run the following in psql from the CSV directory. \copy is a psql command that reads the client-side file; it is not ordinary SQL for a driver to execute.
BEGIN;
\copy accounts(id, name, active) FROM 'accounts.csv' WITH (FORMAT csv, HEADER true)
SELECT setval(pg_get_serial_sequence('accounts', 'id'),
COALESCE(MAX(id), 1), MAX(id) IS NOT NULL)
FROM accounts;
SELECT COUNT(*), MIN(id), MAX(id) FROM accounts;
COMMIT;
Set ON_ERROR_STOP in psql when running a script so failures stop execution. The sequence adjustment prevents the next generated ID from colliding with imported IDs; the empty-table case keeps the first sequence value available. Review whether your application uses identity columns, standalone sequences, or application-generated identifiers before applying it.
This example uses a fresh target table. A second import into the same table will fail on duplicate keys, so repeat rehearsals against a fresh disposable database or implement a deliberate staging-and-upsert strategy. Do not silently truncate an existing production table to make a script repeatable.
See the PostgreSQL COPY reference and sequence functions for the import and sequence behavior. Verify new inserts, not only imported row counts.
Export in a controlled format
For small applications, a migration can be performed with a straightforward export-transform-load process.
Useful intermediate formats include:
- CSV;
- JSONL;
- SQL dumps.
The important part is preserving stable identifiers and relationships.
For larger JSONL-oriented workflows, see From JSONL to PostgreSQL: A Reliable Catalog Data Pipeline.
Make the import repeatable
Avoid a migration script that can only be run once manually.
A better script should:
- validate source rows;
- transform data deterministically;
- use transactions;
- report failures;
- support dry runs;
- avoid creating duplicates;
- print final record counts.
This makes it possible to rehearse the migration before switching production traffic.
Compare before switching
After import, compare both databases.
Useful checks include:
- row counts;
- unique entity counts;
- missing foreign keys;
- null rates;
- sample records;
- aggregate totals.
The goal is not merely to finish the import. It is to prove that the important information survived.
Plan the cutover
A small application can often use this sequence:
- stop writes temporarily;
- perform the final export;
- run the migration;
- verify counts and constraints;
- update the production connection string;
- deploy;
- run smoke tests;
- keep the old database available temporarily for rollback.
For systems that cannot stop writes, a more sophisticated synchronization strategy may be needed.
The old database is a rollback source only until new writes reach PostgreSQL. After that, switching the connection string back can lose those writes. Define a rollback window, a write freeze or replay mechanism, and who decides whether to roll forward instead.
Also test application behavior: parameter placeholders, database-specific SQL, transaction boundaries, timestamp handling, foreign-key enforcement, and connection pooling. A correct import does not prove that every application query is portable.
Use environment variables for configuration
The application should not hard-code database credentials.
A production backend should read a connection URL from the deployment environment.
This makes local SQLite and production PostgreSQL easier to separate without changing application code.
The useful rule
Do not migrate because PostgreSQL is more “professional.”
Migrate when the application needs durability, concurrency, managed operations, or multi-service access that SQLite is not providing comfortably.
That keeps prototypes simple while giving production systems a clear upgrade path.
Need to move a prototype into a durable production setup? Share the current architecture here.