· Titouan Ameline de Cadeville · Engineering  · 9 min read

PostgreSQL vs SQLite, 2026 edition

We benchmarked PostgreSQL 16 against SQLite 3.46.1 under a real CISO Assistant workload on a single 1 vCPU / 8 GB host. The results weren't what we expected — SQLite-WAL won on most read patterns.

We benchmarked PostgreSQL 16 against SQLite 3.46.1 under a real CISO Assistant workload on a single 1 vCPU / 8 GB host. The results weren't what we expected — SQLite-WAL won on most read patterns.

We ran the benchmarks. The results weren’t what we expected.

The question comes up cyclically in architecture discussions: when exactly should you move from SQLite to PostgreSQL? In 2026, the answer is less obvious than ever. SQLite in WAL mode has matured significantly. PostgreSQL remains the reference for distributed workloads. But on a single server, in production, against a real populated database — what does the latency curve actually look like? We wanted to measure it.

The setup

Software versions: PostgreSQL 16, SQLite 3.46.1, Django with psycopg2.

Psycopg3’s pipeline mode would reduce per-query round-trip cost for PostgreSQL, but psycopg2 remains the more battle-tested driver in production and is what CISO Assistant ships with today.

The system under test is the CISO Assistant API, which exposes roughly fifty endpoints. We used Locust to generate load: 10 virtual users, ramped at 2 users/second, 60 seconds per run. Two scenarios:

  • Empty database — single user, freshly initialised schema.
  • Populated database — 1,000 assets, 7,500 incidents, plus TPRM data, findings, and vulnerabilities.

PostgreSQL and SQLite both ran in Docker containers on the same host as the Django backend — an Ubuntu VM with 1 vCPU and 8 GB of RAM, roughly the minimum viable footprint for a fully functional CISO Assistant instance. PostgreSQL was reached via TCP at db:5432 (Docker bridge network on the same host). SQLite, operating in the same process, skips that socket entirely.

PostgreSQL configuration: stock defaults — shared_buffers = 128MB, synchronous_commit = on, no connection pooling. Django’s CONN_MAX_AGE was set to 300 (persistent connections), which already amortises a significant fraction of per-query round-trip cost.

SQLite configuration: WAL mode enabled. Other PRAGMAs (synchronous, cache_size, mmap_size, busy_timeout, temp_store) were left at their defaults.

This framing matters for interpreting the results: SQLite-WAL at reasonable defaults vs PostgreSQL 16 at stock defaults, single-node, same host. This is not a tuned PostgreSQL benchmark.

Statistical note on sample sizes: this was a single 60-second Locust run per configuration across ~50 endpoints with 10 virtual users. Per-endpoint request counts range from 1 to ~12 within that window. Even three runs per scenario with min/median/max reported would materially strengthen these numbers — treat them as directional, not definitive.

Empty database: a near-tie

On a freshly initialised database, both engines perform almost identically. The aggregated p50 (median) is 24ms for PostgreSQL vs 31ms for SQLite — a difference that will rarely matter in practice.

EndpointPostgreSQLSQLite
GET /api/assets/27ms43ms
GET /api/folders/33ms40ms
GET /api/compliance-assessments/41ms43ms
GET /api/risk-assessments/24ms49ms
Aggregated p5024ms31ms

Average response time — empty DB (ms, lower is better)

The PostgreSQL edge here is modest and expected: on small datasets, query execution is trivial for both engines, and PostgreSQL’s planner overhead is negligible. The TCP socket is already amortised by persistent connections.

Populated database: where it gets interesting

With a database holding thousands of objects, the picture changes significantly on most endpoints — and collapses on a few specific ones.

SQLite wins: endpoints with unbounded or deeply nested queries

The common thread on endpoints where SQLite wins is not simply “lists” — it is endpoints where the serialiser issues many sub-queries per row, or where the response is not bounded by pagination: permission checks per object, nested relation fetching, computed tree structures. Each of those sub-queries pays the TCP round-trip cost on PostgreSQL; SQLite resolves them in-process.

EndpointPostgreSQLSQLiteDifference
GET /api/settings/general/2,789ms461msSQ 83% faster
GET /api/get_combined_assessments_status/2,692ms263msSQ 90% faster
GET /api/vulnerabilities/2,259ms333msSQ 85% faster
GET /api/incidents/2,040ms1,591msSQ 22% faster
GET /api/compliance-assessments/649ms180msSQ 72% faster
GET /api/risk-assessments/510ms132msSQ 74% faster

Two further endpoints — GET /api/assets/graph/ and GET /api/folders/org_tree/ — showed multi-second latencies on both engines that point to ORM access patterns rather than database choice. We discuss them separately in the outlier section below.

PostgreSQL wins: endpoints with simple, bounded lookups

PostgreSQL recovers its edge on endpoints that resolve in a small number of well-indexed queries — by primary key, or with tight pagination — where the per-query TCP cost is fixed and low, and the query planner’s precision matters more than round-trip count.

EndpointPostgreSQLSQLiteDifference
GET /api/applied-controls/91ms814msPG 9× faster
GET /api/applied-controls/todo/113ms469msPG 4× faster
GET /api/folders/198ms459msPG 57% faster
GET /api/incidents/:id/225ms1,073msPG 4.8× faster

Total throughput across all endpoints over the 60-second run was 4.1 req/s for PostgreSQL vs 5.1 req/s for SQLite — measured across ~30 common endpoints with uniform Locust distribution. A workload skewed toward simple bounded lookups would shift this ratio toward PostgreSQL.

Why these results?

On unbounded and nested endpoints, PostgreSQL goes through a TCP socket at db:5432. Even with persistent connections (CONN_MAX_AGE = 300), every sub-query incurs a round-trip on that socket. On endpoints that issue many small queries through the ORM — aggregations, permission checks, nested object fetching — that cost accumulates. SQLite, in-process, skips every one of those round-trips.

We did not isolate this directly (e.g. by comparing TCP vs Unix domain socket), so treat this as the most plausible explanation, not a controlled measurement.

On simple bounded lookups, PostgreSQL recovers its edge. Its B-tree indexes and query planner are optimised for these patterns; the per-query TCP cost is fixed and the query itself is minimal.

The GET /api/folders/org_tree/ result at 8,172ms and GET /api/assets/graph/ at 15,031ms are outliers worth flagging independently. Both endpoints likely perform tree traversal either via recursive CTE or Python-side recursion. At these latencies, the ORM access pattern is the primary suspect, not the choice of database engine.

Note on login latency with a populated DB: POST /api/iam/login/ goes from 659ms on the empty database to 1,034ms (PostgreSQL) and 886ms (SQLite) on the populated one. bcrypt cost is data-independent, so the extra latency points to auth-time queries — permissions loading, last-login update, or audit log writes — that scale with data volume.

Writes and mutations: the overlooked half

OperationEndpointPostgreSQLSQLite
POST/api/assets/320ms110ms
POST/api/folders/198ms99ms
DELETE/api/assets/:id/370ms180ms
PATCH/api/applied-controls/:id/620ms870ms

Populated database, 10 virtual users, sequential writes.

On POST and DELETE operations, SQLite wins consistently. These endpoints trigger ORM cascades (child assets, permissions, org_tree rebuild), and each recursive call goes through the TCP socket on PostgreSQL. SQLite resolves the same operations in-process.

PATCH /api/applied-controls/:id/ is the exception: PostgreSQL wins here at 620ms vs 870ms, consistent with its advantage on simple bounded lookups.

The most important caveat on writes: these are sequential writes. SQLite’s WAL mode improves concurrent reads dramatically, but it retains a single-writer constraint. With 10 virtual users, we never stress-tested that ceiling. On a write-heavy concurrent workload, that limit becomes visible — and is the single most important factor to weigh before choosing SQLite for a write-intensive deployment.

Tail latencies

Average response times tell only part of the story — and with per-endpoint sample counts as low as 1 to 11 requests per 60-second run, the per-endpoint percentile data needs careful reading.

Important caveat on per-endpoint percentiles: with n=3 or n=4 samples, Locust’s percentile buckets collapse — p75 through p99.9 all resolve to the same value (the maximum observed request). These numbers are not statistical percentiles in the meaningful sense; they are the worst single observation out of a handful. Read them as “the slowest request we saw”, not as a tail latency estimate. Only the aggregated row (n=242 for PG, n=294 for SQLite) has enough samples to produce meaningful percentile spread.

Here is the full breakdown, showing p50, p75, and p90 per endpoint where they differ, plus the aggregate distribution:

Endpointnavgp50p75p90
PostgreSQL
GET /api/assets/graph/315,031ms15,000ms16,000ms16,000ms
GET /api/folders/org_tree/48,172ms8,600ms12,000ms12,000ms
GET /api/incidents/52,040ms1,900ms2,400ms3,500ms
GET /api/compliance-assessments/11649ms100ms440ms680ms
GET /api/assets/81,713ms920ms4,000ms6,300ms
GET /api/risk-assessments/5510ms480ms620ms1,000ms
GET /api/applied-controls/191ms91ms
GET /api/incidents/:id/1225ms230ms
Aggregated PG2421,387ms640ms1,500ms3,500ms
SQLite
GET /api/assets/graph/48,077ms8,900ms9,200ms9,200ms
GET /api/folders/org_tree/43,860ms4,100ms4,600ms4,600ms
GET /api/incidents/111,591ms1,400ms1,900ms2,600ms
GET /api/compliance-assessments/12180ms110ms340ms370ms
GET /api/assets/6606ms530ms770ms1,500ms
GET /api/risk-assessments/10132ms99ms110ms520ms
GET /api/applied-controls/4814ms890ms1,300ms1,300ms
GET /api/incidents/:id/31,073ms990ms1,500ms1,500ms
Aggregated SQ294784ms500ms1,000ms1,700ms

Populated DB — all times in ms, lower is better. Aggregated p99 (n=242 / n=294): PostgreSQL 14,000ms, SQLite 8,200ms.

The aggregated distribution is the only statistically meaningful picture here. At p90, PostgreSQL sits at 3,500ms vs SQLite’s 1,700ms. At p99, PostgreSQL reaches 14,000ms vs SQLite’s 8,200ms — driven by the assets/graph and org_tree outliers hitting their worst cases under load.

Two individual-endpoint numbers are worth highlighting despite small n: GET /api/compliance-assessments/ on PostgreSQL shows p50=100ms but avg=649ms — extreme variance across just 11 requests, consistent with occasional query plan misses or lock contention. SQLite on the same endpoint is stable: p50=110ms, avg=180ms.

What we did not tune — and what it means

  • PostgreSQL at stock defaults. No async_commit, no connection pooling (PgBouncer or otherwise), no tuned work_mem. A well-configured PostgreSQL would close — and likely reverse — much of the gap on unbounded endpoints.
  • CONN_MAX_AGE = 300 was set, so Django is reusing connections. Per-query round-trip cost is real but partially mitigated.
  • “Per-query round-trip cost” is the most plausible explanation for the aggregate gap; we did not measure it in isolation. Unix domain socket vs TCP would isolate this — we did not run that comparison.
  • Single host, 1 vCPU, 8 GB. Numbers will look different on multi-core hosts or across a real network boundary.
  • Concurrent write benchmark is the natural follow-up — and the one that would actually stress WAL’s single-writer ceiling.
  • A note on aggregates: the conclusion uses p50 (median) — 640ms for PostgreSQL vs 500ms for SQLite. The tail-latency table uses the mean — 1,387ms vs 784ms. The median is more robust to outliers; the mean reflects the full weight of the slow endpoints. Both are valid; they answer different questions.

The real conclusion

On a single well-sized instance running CISO Assistant, SQLite in WAL mode at default configuration competes seriously with PostgreSQL 16 at default configuration — and beats it outright on most read patterns we measured.

The aggregated p50: SQLite 500ms, PostgreSQL 640ms. Total throughput: SQLite 5.1 req/s vs PostgreSQL 4.1 req/s. Aggregated p99: SQLite 8,200ms vs PostgreSQL 14,000ms.

PostgreSQL recovers its advantage on simple bounded lookups, where its query planner and indexing shine — and on any workload that requires replication, multi-node writes, or concurrent write throughput that hits SQLite’s WAL ceiling.

Choose SQLite if you’re on a single-node deployment, your workload is read-heavy, and you value operational simplicity.

Choose PostgreSQL if you need multi-instance deployment, streaming replication, or a concurrent write-heavy workload. These are the right reasons. Latency on a single node is not.

Whatever engine you are considering, make sure your first investment is in query optimisation. A well-profiled ORM access pattern will do more for your response times than any engine switch!

A side note on locality. SQLite’s hard limit isn’t concurrency — it’s locality. As soon as your application runs on more than one host, those instances cannot share a local SQLite file (network filesystems are a footgun; tools like Litestream or LiteFS exist but they are bolt-on solutions, not the default). That is the practical line where PostgreSQL becomes mandatory, well before any latency or throughput consideration enters the picture.

Back to Blog

Related Posts

View All Posts »
Rebuilding the CISO Assistant Documentation

Rebuilding the CISO Assistant Documentation

Why we're restructuring the CISO Assistant documentation — clearer mental models, concepts separated from guides, versioned with the code, and open to community contributions.

What's New in CISO Assistant — Week 21, 2026 (v3.16.3 – v3.16.4)

What's New in CISO Assistant — Week 21, 2026 (v3.16.3 – v3.16.4)

Two releases land back-to-back: v3.16.3 brings the AI Defense Matrix and KSA PDPL frameworks, a Responsibility Matrix (RACI/RASCI/RAPID), Ebios RM import in Egerie format, task labels, and full Estonian language support — followed by a v3.16.4 hotfix round covering Matrix Editor, breadcrumbs, and journey templates.

What's New in CISO Assistant — Week 20, 2026 (v3.16.2)

What's New in CISO Assistant — Week 20, 2026 (v3.16.2)

v3.16.2 brings two new framework libraries (EU CER directive, UK Defence Standard 05-138), an experimental UI mode for asset creation, a specialized wizard for customer questionnaire prefill, the start of CBDDO and DoW ZT-OT framework support, plus a healthy round of audit performance work, mapping engine fixes, and i18n improvements.