SQLite vs PostgreSQL
Both engines run SQL, and 90% of your queries port between them unchanged. The other 10% is what this page is about — plus the operational and CLI differences you meet on day one.
Architecture
Section titled “Architecture”SQLite is a C library you link into your process. Your application calls functions; those functions read and write a single file with pread/pwrite. There is no separate process, no socket, no authentication, no configuration file. “Connecting” means opening a file.
The consequences follow directly:
- Zero operational surface. Nothing to install, monitor, patch, back up separately, or secure on a network.
- Zero network latency. A query is a function call — microseconds, not a round trip.
- No access control. Anyone who can read the file can read every row. Filesystem permissions are the only security boundary.
- Only processes on the same machine (and same filesystem) can use it. Network filesystems like NFS are explicitly unsupported for concurrent access, because their locking is unreliable.
PostgreSQL is a server. A postmaster process forks a backend per connection; backends share memory and coordinate through a WAL, a background writer, autovacuum workers, and more. Clients speak a wire protocol over TCP or a Unix socket.
- Roles, passwords,
pg_hba.conf, TLS, row-level security. - Many machines can connect at once; connection pooling (PgBouncer) becomes a thing you think about.
- Replication, point-in-time recovery, logical decoding.
- Real operational cost: memory tuning, vacuum, upgrades, monitoring.
| SQLite | PostgreSQL | |
|---|---|---|
| Process model | In-process library | Server + one backend process per connection |
| Storage | One file (+ -wal, -shm) |
A data directory managed by the server |
| Connection | sqlite3_open("app.db") |
TCP/Unix socket + authentication |
| Query latency | Function call | Network round trip (~0.1–1 ms locally) |
| Access control | Filesystem permissions | Roles, pg_hba.conf, TLS, RLS |
| Backup | Copy the file, VACUUM INTO, .backup |
pg_dump, pg_basebackup, WAL archiving |
| Extension model | Loadable C extensions (FTS5, R-Tree, …) | CREATE EXTENSION (PostGIS, pgvector, …) |
Typing
Section titled “Typing”Covered in depth in data types and DDL; the summary:
SQLite is dynamically typed. Values carry types, columns don’t. A declared type only sets an affinity — a preferred conversion attempted on insert. There are five storage classes: NULL, INTEGER, REAL, TEXT, BLOB. No boolean, no date, no decimal, no UUID, no arrays. STRICT tables (3.37+) opt into real enforcement, but only for those five types plus ANY.
PostgreSQL is statically typed with a large and extensible type system: exact numeric, boolean, date/time/timestamptz/interval, uuid, bytea, inet, json/jsonb, ranges, arrays of any type, user-defined enums and composites, and types added by extensions.
The practical fallout:
-- SQLite: no error, stores the text 'oops' in an INTEGER columnINSERT INTO books (id, title, year) VALUES (9, 'X', 'oops');
-- PostgreSQLERROR: invalid input syntax for type integer: "oops"SQLite’s leniency is genuinely useful for exploratory work on messy data and genuinely dangerous in production. Use STRICT.
Concurrency
Section titled “Concurrency”This is the single biggest functional difference.
SQLite: one writer at a time, database-wide. Locks are taken on the whole file, not on rows or tables. In the default rollback-journal mode a writer also blocks readers. In WAL mode (PRAGMA journal_mode = WAL) readers and the writer proceed concurrently, but there is still exactly one writer.
That limit is fine for a huge number of workloads — a single application server doing a few hundred writes per second, with each write taking under a millisecond, never notices. It is fatal for many application servers writing to a shared file.
PRAGMA journal_mode = WAL;PRAGMA busy_timeout = 5000; -- wait up to 5s for the write lock instead of erroringPostgreSQL: MVCC with row-level locking. Each UPDATE writes a new row version; old versions remain visible to older snapshots and are later reclaimed by vacuum. Readers never block writers; writers never block readers; two writers conflict only when they touch the same row.
| SQLite | PostgreSQL | |
|---|---|---|
| Concurrent readers | Many | Many |
| Concurrent writers | One, database-wide | Many, conflicting only per row |
| Reader/writer blocking | None in WAL mode | None |
| Isolation levels | Serializable in effect | Read Committed (default), Repeatable Read, Serializable |
| Row locks | None | FOR UPDATE, FOR SHARE, SKIP LOCKED |
| Deadlocks | Not possible between statements | Possible; detected and one transaction aborted |
| Space reclamation | VACUUM (manual, rewrites the file) |
Autovacuum (automatic, background) |
Features PostgreSQL has and SQLite doesn’t
Section titled “Features PostgreSQL has and SQLite doesn’t”| Feature | Notes |
|---|---|
| Arrays | text[], integer[], with ANY, unnest, GIN indexes |
| Enums | CREATE TYPE ... AS ENUM |
| Ranges | int4range, tstzrange, with exclusion constraints |
| Stored procedures & functions | PL/pgSQL, plus PL/Python, PL/Perl, SQL functions |
| Triggers with full procedural logic | SQLite triggers exist but bodies are SQL statements only |
| Materialized views | REFRESH MATERIALIZED VIEW [CONCURRENTLY] |
| Full-text search | tsvector/tsquery, GIN indexes, ranking, stemming — built in |
LISTEN / NOTIFY |
Pub/sub from inside the database |
| Extensions | PostGIS (geo), pgvector (embeddings), pg_stat_statements, TimescaleDB |
| Partitioning | Declarative range/list/hash partitioning |
| Replication | Streaming and logical replication, read replicas |
| Foreign data wrappers | Query other databases as if they were local tables |
| Row-level security | CREATE POLICY |
| Parallel query | Multiple workers per query |
| Advanced index types | GIN, GiST, BRIN, SP-GiST, hash |
GROUPING SETS/ROLLUP/CUBE, LATERAL, MERGE, DISTINCT ON |
SQLite has its own answers to a few of these: FTS5 for full-text search, R-Tree for spatial indexing, and generated columns — all as compiled-in extensions.
Features SQLite has that PostgreSQL doesn’t
Section titled “Features SQLite has that PostgreSQL doesn’t”Fewer, but real:
- Zero-configuration deployment. Ship a file.
VACUUM INTO 'backup.db'— an atomic, consistent single-file backup with one statement.- In-memory databases (
:memory:) — perfect for tests. ATTACH DATABASE— query across several database files in one statement.- The database is the file format. Application state, save files, and data interchange in one artefact.
sqlite3_analyzer,.dump,.recover— file-level forensics on a corrupt database.- Radically simpler: the entire engine is a few hundred thousand lines with famously exhaustive test coverage.
Auto-incrementing keys
Section titled “Auto-incrementing keys”| SQLite | PostgreSQL | |
|---|---|---|
| Idiomatic form | id INTEGER PRIMARY KEY |
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY |
| Legacy form | INTEGER PRIMARY KEY AUTOINCREMENT |
id serial PRIMARY KEY / bigserial |
| Mechanism | Alias for the hidden rowid |
A sequence object with a column default |
| Reuse after delete | Yes (unless AUTOINCREMENT) |
No |
| Get the new value | RETURNING id (3.35+) or last_insert_rowid() |
RETURNING id or currval('seq') |
Two traps:
- In SQLite the auto-numbering only happens when the type is written exactly
INTEGER.INT PRIMARY KEYorBIGINT PRIMARY KEYis a plain column that will not auto-fill. - In PostgreSQL, loading data with explicit IDs into a
serial/IDENTITY BY DEFAULTcolumn leaves the sequence behind, and the next insert collides. Fix it after a load:
SELECT setval(pg_get_serial_sequence('books', 'id'), COALESCE(MAX(id), 1)) FROM books;Date and time handling
Section titled “Date and time handling”The biggest source of migration bugs.
SQLite has no date type. Store one of three shapes and be consistent:
| Shape | Example | Notes |
|---|---|---|
| ISO-8601 text | '2026-08-09 12:00:00' |
Sorts and compares correctly as text. The usual choice. |
| Unix seconds (integer) | 1786377600 |
Compact, needs conversion for display. |
| Julian day (real) | 2461262.0 |
Rarely used outside date arithmetic. |
-- SQLiteSELECT date('now'), datetime('now', '+7 days'), strftime('%Y-%m', '2026-08-09');SELECT datetime(1786377600, 'unixepoch');SELECT unixepoch('2026-08-09T12:00:00Z');There is no time-zone type. 'localtime' and 'utc' modifiers convert using the OS zone, which makes results machine-dependent — store UTC.
PostgreSQL has real temporal types. timestamptz stores an absolute instant and renders it in the session’s time zone. Interval arithmetic is native.
-- PostgreSQLSELECT now(), now() + interval '7 days', date_trunc('month', now());SELECT to_timestamp(1786377600);SELECT EXTRACT(epoch FROM now())::bigint;SELECT now() AT TIME ZONE 'Europe/Paris';| Task | SQLite | PostgreSQL |
|---|---|---|
| Now | datetime('now') |
now() / current_timestamp |
| Today | date('now') |
current_date |
| Add 7 days | date(d, '+7 days') |
d + interval '7 days' |
| Start of month | date(d, 'start of month') |
date_trunc('month', d) |
| Format | strftime('%Y-%m-%d', d) |
to_char(d, 'YYYY-MM-DD') |
| Extract year | CAST(strftime('%Y', d) AS INTEGER) |
EXTRACT(year FROM d) |
| Difference in days | julianday(a) - julianday(b) |
a::date - b::date |
| To Unix seconds | unixepoch(d) |
EXTRACT(epoch FROM d) |
Choosing between them
Section titled “Choosing between them”Choose SQLite when:
- The database is used by exactly one process, or by processes on one machine.
- Writes are modest and short — a single writer is not a bottleneck.
- You want zero operations: desktop apps, mobile apps, CLI tools, embedded devices.
- You need a test database that starts empty in milliseconds.
- You are analysing a data file someone handed you.
- The data is content that changes rarely and is read constantly (a docs site, a catalogue).
Choose PostgreSQL when:
- More than one application server writes to the same data.
- You need real types — money as
numeric, instants astimestamptz, documents as indexedjsonb. - You need concurrent write throughput, or long analytical queries running beside OLTP traffic.
- You need replication, point-in-time recovery, or read replicas.
- You need features only it has: full-text search, PostGIS, pgvector, partitioning, row-level security.
- The data will outgrow one machine’s memory, or one machine entirely.
Migration checklist: SQLite → PostgreSQL
Section titled “Migration checklist: SQLite → PostgreSQL”Work through this before moving data.
Schema
- Replace
INTEGER PRIMARY KEY [AUTOINCREMENT]withinteger GENERATED ALWAYS AS IDENTITY PRIMARY KEY(orbigint). - Give every column a real type:
BOOLEANcolumns holding 0/1 becomeboolean; text dates becometimestamptzordate; money columns becomenumeric(p, s), neverreal. - Replace
TEXTused for JSON withjsonbif you query it. - Convert
CHECK (x IN (...))to an enum or keep the check — both work. - Add
NOT NULLwhere SQLite let nulls into primary keys. - Recreate indexes; add an index on every foreign key column (SQLite tolerated their absence better than you think, PostgreSQL will not on cascades).
- Lowercase all identifiers, or be ready to quote them forever.
Data
- Convert booleans:
0/1→false/true. - Normalise timestamps to a single ISO-8601 UTC format before loading.
- Find and fix rows where SQLite’s dynamic typing stored the wrong type:
SELECT * FROM t WHERE typeof(col) <> 'integer'; - Fix orphaned foreign key rows:
PRAGMA foreign_key_check;in SQLite before exporting. - After loading with explicit IDs,
setvalevery sequence.
SQL
-
IFNULL(a, b)→COALESCE(a, b). -
GROUP_CONCAT(x, ',')→STRING_AGG(x, ','). -
INSTR(a, b)→STRPOS(a, b)(note the argument order is the same). -
PRINTF/FORMATformat strings — verify; PostgreSQL’sFORMATuses%s/%I/%L, not C-style width specifiers. -
strftime/julianday/datetime→to_char/date_trunc/interval arithmetic. -
INSERT OR REPLACE/REPLACE INTO→INSERT ... ON CONFLICT ... DO UPDATE. -
INSERT OR IGNORE→INSERT ... ON CONFLICT DO NOTHING. -
"double quoted strings"→'single quoted strings'. - Bare columns in
GROUP BY→ add them toGROUP BYor wrap in an aggregate. -
LIKEused case-insensitively →ILIKEorLOWER(x) LIKE LOWER(y). - Add
NULLS FIRST/NULLS LASTwhere null ordering matters — the defaults are opposite. - Integer division
a / bonintegercolumns behaves the same, butROUND(x, n)in PostgreSQL needsnumeric:ROUND(x::numeric, 2). -
PRAGMAstatements have no PostgreSQL equivalent — delete them, or replace withSET/ALTER SYSTEMwhere applicable.
Behaviour
- Wrap risky statements in savepoints: in PostgreSQL any error aborts the whole transaction.
- Add retry logic if you use
REPEATABLE READorSERIALIZABLE— serialization failures (SQLSTATE 40001) are expected and must be retried. - Re-check every place you relied on SQLite returning the first row of a multi-row scalar subquery; PostgreSQL errors instead.
Tooling. pgloader automates much of the schema and data conversion. Treat its output as a first draft, not the final schema — it maps SQLite’s loose types conservatively, and you will want to tighten them.
psql cheat sheet
Section titled “psql cheat sheet”Backslash commands are client-side; they take no semicolon. \? lists them all, \h SELECT shows SQL syntax help.
Connecting and sessions
psql -d bookstore connect to a local databasepsql -h host -p 5432 -U user -d db connect over TCPpsql "postgresql://user:pw@host/db" connection URIpsql -c "SELECT 1" -d db run one statement and exitpsql -f script.sql -d db run a filepsql -v ON_ERROR_STOP=1 -f script.sql abort the script on the first errorpsql -At -c "SELECT 1" unaligned, tuples-only (script-friendly output)Navigation
\l list databases \c dbname connect to another database\conninfo show current connection \q quit\! cmd run a shell command \cd dir change directoryInspecting the schema
\dt list tables \dt+ with size and description\d name describe a table/view/index/sequence\d+ name describe with storage, comments, and view definitions\di indexes \dv views\dm materialized views \ds sequences\dn schemas \df functions\du roles \dT data types\dp table privileges \sf funcname show a function's source\dt *.* include system schemas\dt pattern filter by name pattern, e.g. \dt book*Output control
\x toggle expanded (one column per line) display\x auto expand only when rows are too wide\pset null '∅' how to render NULL\pset format csv output format: aligned, csv, html, json...\timing on report execution time for every statement\a toggle aligned/unaligned output\o file.txt send subsequent output to a file (\o alone stops)Running things
\i file.sql execute a file\e open the last query in $EDITOR, run it on save\ef funcname edit a function definition\g re-run the last query; \gx re-run with expanded output\watch 2 re-run the last query every 2 seconds\copy books FROM 'b.csv' CSV HEADER client-side import (no server file access needed)\copy (SELECT * FROM books) TO 'b.csv' CSV HEADERUseful SQL-side commands
SHOW ALL; -- every configuration settingSELECT version();SELECT pg_size_pretty(pg_database_size(current_database()));SELECT pg_size_pretty(pg_total_relation_size('books'));SELECT * FROM pg_stat_activity WHERE state <> 'idle';sqlite3 cheat sheet
Section titled “sqlite3 cheat sheet”Dot commands are client-side; they take no semicolon and must start at the beginning of a line. .help lists them all.
Starting
sqlite3 app.db open (creates the file if missing)sqlite3 :memory: temporary in-memory databasesqlite3 app.db "SELECT COUNT(*) FROM books;" run one statement and exitsqlite3 app.db < script.sql run a filesqlite3 -header -csv app.db "SELECT * FROM books;" > books.csvsqlite3 app.db ".dump" > dump.sql full SQL dumpInspecting
.tables list tables.schema full schema.schema books one table's CREATE statement.fullschema schema plus stored ANALYZE statistics.indexes books indexes on a table.databases attached databases and their files.dbinfo header details: page size, encoding, journal modeOutput formatting
.headers on show column names.mode box bordered table (3.33+).mode column aligned columns.mode line one field per line — good for wide rows.mode csv comma-separated.mode json JSON array of objects.mode insert books emit INSERT statements.mode markdown Markdown table.nullvalue NULL how to render NULL.width 20 40 fixed column widths for column modeImport, export, backup
.import --csv --skip 1 books.csv books load a CSV into a table.output out.txt redirect results to a file (.output alone restores stdout).once out.txt redirect only the next command's output.read script.sql execute a file.dump SQL text dump of the whole database.backup backup.db online binary backup, safe while other connections write.save backup.db write the current database to a new fileVACUUM INTO 'backup.db'; -- SQL equivalent of .backup, 3.27+Diagnostics
.timer on wall-clock time per statement.stats on per-statement counters.eqp on automatically show EXPLAIN QUERY PLAN for each statement.changes on report rows changed by each statement.lint fkey-indexes suggest indexes for unindexed foreign keys.quit exit (.exit works too)PRAGMAs worth knowing
PRAGMA foreign_keys = ON; -- enforce foreign keys (OFF by default!)PRAGMA journal_mode = WAL; -- concurrent readers with a writer; persistentPRAGMA busy_timeout = 5000; -- ms to wait for a lock before erroringPRAGMA synchronous = NORMAL; -- faster; safe against crashes in WAL modePRAGMA table_info(books); -- columns, types, nullability, defaultsPRAGMA index_list(books); -- indexes on a tablePRAGMA foreign_key_check; -- report orphaned rowsPRAGMA integrity_check; -- verify the whole filePRAGMA optimize; -- run before closing a long-lived connectionPRAGMA user_version; -- a free integer you can use for schema versioningSELECT sqlite_version(); -- which features you actually haveKey points
Section titled “Key points”- SQLite is an in-process library over one file; PostgreSQL is a server with roles, networking, and replication.
- SQLite’s typing is dynamic (use
STRICT); PostgreSQL’s is static, rich, and extensible. - SQLite allows one writer at a time database-wide; PostgreSQL does row-level MVCC with many concurrent writers.
- PostgreSQL adds arrays, enums, materialized views, full-text search,
LISTEN/NOTIFY, extensions, partitioning, and advanced index types. - SQLite adds zero-configuration deployment, in-memory databases,
ATTACH, and single-file backups. - Date/time is the biggest migration hazard: SQLite has functions over text/integers, PostgreSQL has real types.
- Writing portable SQL from day one makes SQLite → PostgreSQL a mechanical migration; the checklist above covers the real differences.
\?and.helpare the two commands to remember — everything else follows from them.