Postgres and SQLite both speak SQL. Both support transactions, indexes, and standard relational data modeling. But they are designed for fundamentally different environments, and comparing them is less like choosing between two competitors and more like choosing between a local file system and a network file server. The use cases don't overlap as much as developers expect.
This post covers the genuine trade-offs, where SQLite outperforms the expectation, where Postgres is the only appropriate choice, and what happens when either database is asked to do analytical work at scale.
The architectural difference
SQLite is a C library embedded directly in your application process. There is no server, no network protocol, no authentication layer, and no concurrent write access from multiple processes. The database is a single file on disk. Your application reads and writes it directly through the SQLite library.
Postgres is a client-server database. A separate server process manages connections, handles authentication, enforces permissions, and coordinates concurrent reads and writes from multiple clients across a network. The database is a directory of files managed by the server.
This difference is not a quality difference. It is a design choice that reflects different requirements:
- SQLite is correct for embedded applications, local tools, mobile apps, desktop software, and development environments.
- Postgres is correct for web applications, APIs, and any system where multiple clients access the same data concurrently over a network.
Where SQLite excels
Zero-configuration deployment
SQLite requires no installation beyond the library itself, no configuration files, no server process to start, and no user accounts to create. For a local CLI tool, a mobile app, or a test suite that needs a real database, SQLite is the fastest possible path:
import sqlite3
conn = sqlite3.connect("analytics.db")
conn.execute("""
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY,
user_id TEXT NOT NULL,
event_type TEXT NOT NULL,
occurred_at TEXT NOT NULL DEFAULT (datetime('now')),
amount REAL
)
""")
conn.commit()
No connection string, no server address, no password. The database exists when the file exists.
Predictable performance for single-writer workloads
SQLite in WAL (Write-Ahead Logging) mode supports one concurrent writer plus multiple concurrent readers. For a local application where a single process writes data and reads it back, SQLite's performance is excellent. The absence of network round-trips, authentication overhead, and connection management means simple read/write operations are faster than the equivalent Postgres query for single-client workloads.
The SQLite documentation notes WAL mode provides better concurrency than the default journal mode and is the recommended mode for most applications. WAL mode allows readers and writers to operate concurrently without blocking each other.
Simplicity as a feature
SQLite has a minimal type system: five storage classes (NULL, INTEGER, REAL, TEXT, BLOB). Column types are suggestions, not constraints. A column declared as INTEGER will store 'hello' without complaint. This flexibility is a problem in production systems that require data integrity, but for development data, local caches, and embedded configuration storage, it removes friction.
SQLite databases are also trivial to copy, inspect, version-control, and restore. A database backup is cp analytics.db analytics.db.bak. For tools that need portable data, SQLite files work as a distribution format.
Where Postgres excels
Concurrent writes from multiple clients
Postgres uses MVCC (Multi-Version Concurrency Control) to allow multiple writers to update different rows in the same table simultaneously without blocking each other. A web application handling thousands of requests per second, each writing to the database, needs this. SQLite's single-writer constraint means concurrent writes queue up, and under high write load, throughput degrades.
Any application with more than one writer, whether multiple application servers hitting the same database, background workers alongside a web server, or multiple users modifying shared data, needs Postgres.
Rich type system and constraints
Postgres enforces data types, NOT NULL constraints, foreign keys, and check constraints at the database level:
CREATE TABLE orders (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
customer_id UUID NOT NULL REFERENCES customers(id),
amount NUMERIC(12,2) CHECK (amount > 0),
status TEXT NOT NULL CHECK (status IN ('pending', 'confirmed', 'shipped', 'cancelled')),
currency CHAR(3) NOT NULL DEFAULT 'USD',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
SQLite's type affinity system means constraints are advisory rather than enforced. You can insert a string into an INTEGER column and SQLite will store it. Data integrity in SQLite is the application's responsibility. For multi-developer teams or production systems where data quality matters, this is a meaningful difference.
Full SQL support
Postgres implements more of the SQL standard than SQLite. Notable gaps in SQLite's SQL support:
- No
RIGHT OUTER JOINorFULL OUTER JOIN(added in SQLite 3.39.0, but older deployments lack it) - No
ALTER TABLE ... DROP COLUMNin versions before 3.35.0 - No window function support before SQLite 3.25.0
- No support for
GENERATED ALWAYS ASstored computed columns - No native UUID type, no
ARRAYtype, noJSONBtype
For applications that need complex queries, recursive CTEs, window functions, or JSONB indexing, Postgres is the correct choice and SQLite will require workarounds or application-level logic.
Extensions and replication
Postgres's extension ecosystem (PostGIS, pgvector, TimescaleDB, Citus) adds capabilities that SQLite cannot match. Postgres logical replication provides point-in-time recovery, streaming replication to read replicas, and change data capture for downstream systems. SQLite has no built-in replication and no equivalent to logical replication slots.
Connection and deployment model comparison
The deployment difference between SQLite and Postgres is not just about scale: it affects how applications are structured.
SQLite lives inside the application process. The database file path is a config value. Backups are file copies. There are no connection pools, no network timeouts, and no authentication configuration. For a CLI tool or desktop app, this simplicity is the right default.
Postgres lives in a separate process, often on a separate machine. Connecting requires a host, port, database name, username, and password. Connection pools (PgBouncer, pgpool-II, or built-in pooling in managed services) are a standard part of production Postgres deployments because opening a new database connection is expensive. A busy web application with 100 concurrent requests opening individual connections without pooling can exhaust Postgres's max_connections limit.
For development environments, SQLite eliminates the Postgres setup step entirely. Many teams run SQLite locally and Postgres in production. This works well when the application avoids Postgres-specific features (JSONB operators, advisory locks, window functions that differ from SQLite's implementation). The risk is that bugs caused by the behavioral differences between SQLite and Postgres don't appear until production.
A useful middle ground: use Postgres locally via Docker and accept the setup cost, ensuring development and production behave identically. Managed services like Supabase and Neon provide Postgres with minimal setup, reducing the local configuration burden significantly.
Analytics limits: where both fail
Neither Postgres nor SQLite is designed for analytical queries over large datasets. Both use row-oriented storage, so aggregation queries read every column in every matching row even when only two columns are needed. Analytics workloads on Postgres degrade at scale for the same reason they degrade on any row-oriented OLTP database: columnar compression, vectorized execution, and skip-based reads don't exist.
For SQLite, the limits arrive earlier. SQLite is optimized for single-user read/write patterns, not for analytical scans over tens of millions of rows on shared infrastructure. Running business intelligence queries against a production SQLite database is not a typical deployment pattern.
When evaluating the right OLAP solution for analytics, the answer is almost always to separate OLTP from OLAP: keep Postgres for writes and transactional reads, and add a columnar analytical database for aggregations and reporting. SQLite teams that grow typically migrate to Postgres before they reach the analytics question.
Choosing between them
| Scenario | Recommendation |
|---|---|
| Local CLI tool or desktop application | SQLite |
| Mobile app with local storage | SQLite |
| Test suite with database dependencies | SQLite |
| Embedded configuration or cache | SQLite |
| Portable database as a file | SQLite |
| Multi-client web application | Postgres |
| Multiple concurrent writers | Postgres |
| Foreign keys with enforcement | Postgres |
| JSONB, PostGIS, pgvector workloads | Postgres |
| Replication, HA, streaming CDC | Postgres |
| Analytics over millions of rows | Add ClickHouse® |
When SQLite teams move to Postgres
The signal that it is time to move from SQLite to Postgres is concurrent write pressure: multiple application servers hitting the same database, background workers that need to write while the web server is writing, or users modifying shared data simultaneously. The single-writer constraint in SQLite becomes a bottleneck before any other limit.
Understanding what developers need to know about real-time databases covers the transition from embedded to client-server well. The migration from SQLite to Postgres is relatively straightforward because both use SQL, but schema differences (strict types, explicit primary keys with UUID vs ROWID, TIMESTAMPTZ vs TEXT dates) require attention.
Adding analytics to Postgres
The best database for OLAP is not Postgres. The production pattern for teams that have outgrown OLTP analytics is to stream data from Postgres into a columnar store. ClickHouse handles aggregation queries over billions of rows that would time out on Postgres.
Data engineering for developers is the approach: CDC from Postgres captures every committed row change, transforms it into an append-optimized ClickHouse table, and makes it queryable in seconds. Your application never touches the analytical layer directly.
Tinybird for Postgres teams
Tinybird is managed ClickHouse that connects to Postgres via CDC. Logical replication slots capture every committed change. Tinybird datasources receive them within seconds. SQL Pipes define the analytical queries. HTTP endpoints expose them to your product.
For teams moving from SQLite to Postgres and building toward analytics, clickhouse query optimization covers the patterns that matter when your analytical queries need to run against billions of rows: sort keys, materialized views, LowCardinality columns, and pre-aggregation strategies that bring query times from seconds to milliseconds.
Resend, processing 100TB per month on Tinybird, measured 62ms p90 query latency in production without caching. Their team built a complete Marketing Analytics feature in two weeks, starting from a Postgres database that was timing out on simple aggregate queries. Tinybird is SOC 2 Type II certified.
The pipeline from Postgres change to queryable HTTP endpoint is hours to configure. Postgres stays exactly where it is. Tinybird handles the analytical layer, scaling independently of your OLTP workload.
