|
| 1 | +# sqlite-orm-bench |
| 2 | + |
| 3 | +> **SQLite handles 88K writes/sec. Your ORM caps at 3,800. PRAGMA tuning won't save you.** |
| 4 | +
|
| 5 | +A benchmark harness that quantifies the **ORM tax** on SQLite write performance at 10M–50M row scale. Run an 11-configuration sweep, or compare SQLAlchemy ORM vs raw `executemany` head-to-head. Reproduce our results on your hardware. |
| 6 | + |
| 7 | +[](https://www.python.org/downloads/) |
| 8 | +[](LICENSE) |
| 9 | +[](ARTICLE.md) |
| 10 | + |
| 11 | +--- |
| 12 | + |
| 13 | +## Headline Results |
| 14 | + |
| 15 | +| Path | 10M rows | 50M rows | p99 | |
| 16 | +|------|----------|----------|-----| |
| 17 | +| SQLAlchemy ORM | **3,696 r/s** (45 min) | **3,682 r/s** (3.8 hrs) | 1,492 ms | |
| 18 | +| Raw `executemany` | **87,893 r/s** (1.9 min) | **65,742 r/s** (12.7 min) | 478 ms | |
| 19 | +| **Speedup** | **23.8×** | **17.9×** | **3.1×** | |
| 20 | + |
| 21 | +Across **11 PRAGMA configurations** (sync modes, cache sizes, pool sizes, mmap, chunk sizes), ORM throughput varied only **26%** (3,045–3,821 r/s). The database isn't your bottleneck — your ORM is. |
| 22 | + |
| 23 | +Zero errors across 110 million rows. Every config. Every scale. |
| 24 | + |
| 25 | +→ Full writeup: [**ARTICLE.md**](ARTICLE.md) |
| 26 | + |
| 27 | +--- |
| 28 | + |
| 29 | +## What This Benchmarks |
| 30 | + |
| 31 | +Two complementary harnesses: |
| 32 | + |
| 33 | +1. **`scale_benchmark`** — Sweeps 11 SQLite configurations against a fixed scale (default 10M rows). Tests whether PRAGMA tuning matters. |
| 34 | +2. **`before_after`** — Compares SQLAlchemy ORM vs raw `sqlite3.executemany` at multiple scales (default 10M and 50M). Measures the ORM overhead directly. |
| 35 | + |
| 36 | +Both: |
| 37 | +- Stream rows via a generator (constant memory regardless of scale) |
| 38 | +- Capture checkpoint metrics (throughput, p50/p95/p99, RSS, DB size, errors) |
| 39 | +- Save results as JSON with `--resume` support (50M ORM runs take 3+ hours; crashes happen) |
| 40 | +- Generate HTML reports with Chart.js |
| 41 | + |
| 42 | +--- |
| 43 | + |
| 44 | +## Quickstart |
| 45 | + |
| 46 | +```bash |
| 47 | +# Clone + install |
| 48 | +git clone https://github.com/TanayK07/sqlite-orm-bench.git |
| 49 | +cd sqlite-orm-bench |
| 50 | +pip install -e . |
| 51 | + |
| 52 | +# Smoke test (≈ 5 seconds) |
| 53 | +python -m sqlite_bench.before_after --scales 10K --mode both |
| 54 | + |
| 55 | +# ORM vs Raw at 10M (≈ 50 minutes) |
| 56 | +python -m sqlite_bench.before_after --scales 10M --mode both |
| 57 | + |
| 58 | +# Full 11-config sweep × 10M (≈ 5.5 hours) |
| 59 | +python -m sqlite_bench.scale_benchmark --scales 3M,5M,10M --configs all |
| 60 | + |
| 61 | +# Generate HTML report |
| 62 | +python -m sqlite_bench.report results/scale/results.json |
| 63 | +``` |
| 64 | + |
| 65 | +--- |
| 66 | + |
| 67 | +## Repo Layout |
| 68 | + |
| 69 | +``` |
| 70 | +sqlite-orm-bench/ |
| 71 | +├── README.md # This file (hook + quickstart) |
| 72 | +├── ARTICLE.md # Long-form writeup with industry comparison |
| 73 | +├── LICENSE # MIT |
| 74 | +├── pyproject.toml # Package metadata |
| 75 | +├── sqlite_bench/ # Python package |
| 76 | +│ ├── schema.py # Generic benchmark table |
| 77 | +│ ├── configs.py # PRAGMA presets + parametric sweeps |
| 78 | +│ ├── engine.py # SQLAlchemy engine factories |
| 79 | +│ ├── data_generator.py # Streaming row generator |
| 80 | +│ ├── paths.py # ORM + raw write paths |
| 81 | +│ ├── monitor.py # RSS / CPU / DB-size sampler |
| 82 | +│ ├── results.py # Percentiles + CSV/summary writers |
| 83 | +│ ├── scale_benchmark.py # CLI: config sweep |
| 84 | +│ ├── before_after.py # CLI: ORM vs raw comparison |
| 85 | +│ └── report.py # HTML report generator |
| 86 | +├── docs/ |
| 87 | +│ ├── methodology.md # Test design + decisions |
| 88 | +│ ├── findings.md # Detailed analysis |
| 89 | +│ └── reproducing.md # Hardware-by-hardware notes |
| 90 | +├── examples/ |
| 91 | +│ └── hybrid_repository.py # Production pattern: ORM for CRUD, raw for bulk |
| 92 | +└── results/sample/ # Our actual run data (10M + 50M) |
| 93 | + ├── scale_results.json |
| 94 | + ├── scale_report.html |
| 95 | + ├── before_after_results.json |
| 96 | + └── before_after_report.html |
| 97 | +``` |
| 98 | + |
| 99 | +--- |
| 100 | + |
| 101 | +## Configurations Tested |
| 102 | + |
| 103 | +11 configs cover the obvious axes any production engineer might tune: |
| 104 | + |
| 105 | +| Group | Configs | What changes | |
| 106 | +|-------|---------|--------------| |
| 107 | +| **Presets** | `baseline`, `optimized`, `aggressive` | Realistic deployment profiles | |
| 108 | +| **Chunk size sweep** | `chunk_1000` → `chunk_50000` | Transaction batching impact | |
| 109 | +| **Pool size sweep** | `pool_3`, `pool_5`, `pool_8` | Connection-pool contention | |
| 110 | + |
| 111 | +Each config defines: `chunk_size`, `synchronous`, `cache_size`, `pool_size`, `max_overflow`, `mmap_size`, `busy_timeout`, `journal_mode`, `temp_store`. |
| 112 | + |
| 113 | +See [`sqlite_bench/configs.py`](sqlite_bench/configs.py) for defaults and how to add your own. |
| 114 | + |
| 115 | +--- |
| 116 | + |
| 117 | +## Five Findings |
| 118 | + |
| 119 | +1. **The ORM is the bottleneck, not SQLite.** 11 configs × 10M rows. Throughput varies only 26%. Raw `executemany` on the same hardware hits 87K r/s — 23× faster. |
| 120 | +2. **PRAGMA tuning is irrelevant for ORM workloads.** `sync=OFF` doesn't help. 256MB mmap doesn't help. 8-connection pool doesn't help. The ORM consumes the CPU budget before I/O matters. |
| 121 | +3. **Chunk size controls latency, not throughput.** p99 scales **66×** across chunk sizes (313 ms → 20,508 ms). Throughput drops only 20%. Use 1K–5K chunks for predictable latency. |
| 122 | +4. **Baseline config wins.** `sync=NORMAL`, default cache, `pool=5`, `chunk=5000`. No exotic PRAGMAs needed. Matches production consensus across 7 referenced sources. |
| 123 | +5. **QueuePool eliminates concurrency errors.** Zero `database is locked` errors across 110M rows. QueuePool serializes writes at the application level, matching the single-writer architecture every production SQLite deployment converges on. |
| 124 | + |
| 125 | +→ Detailed analysis with industry comparison: [**ARTICLE.md**](ARTICLE.md) |
| 126 | + |
| 127 | +--- |
| 128 | + |
| 129 | +## When to Bypass Your ORM |
| 130 | + |
| 131 | +| Scenario | Recommendation | |
| 132 | +|----------|---------------| |
| 133 | +| Single-row CRUD | Use the ORM | |
| 134 | +| < 1K rows per transaction | Use the ORM | |
| 135 | +| 10K–1M bulk load | Consider raw SQL (5–10× faster) | |
| 136 | +| > 1M bulk load | Use raw SQL (18–24× faster) | |
| 137 | +| Sustained > 1K writes/sec | Use raw SQL (ORM caps at 3.8K) | |
| 138 | + |
| 139 | +The hybrid pattern (ORM for normal CRUD, raw `executemany` for bulk paths) is in [`examples/hybrid_repository.py`](examples/hybrid_repository.py). |
| 140 | + |
| 141 | +--- |
| 142 | + |
| 143 | +## Reproducing Our Numbers |
| 144 | + |
| 145 | +Hardware we measured on: |
| 146 | + |
| 147 | +| Component | Spec | |
| 148 | +|-----------|------| |
| 149 | +| CPU | 8-core / 16-thread | |
| 150 | +| RAM | 23.2 GB DDR | |
| 151 | +| Storage | Samsung 990 EVO Plus 1TB NVMe | |
| 152 | +| OS | Linux 6.8.0 | |
| 153 | +| Python | 3.11 | |
| 154 | +| SQLAlchemy | 2.0 | |
| 155 | + |
| 156 | +Your absolute numbers will differ on EBS, spinning disk, or eMMC — but the **relative findings** (ORM-to-raw ratio, config irrelevance, chunk-size effect on latency) should hold. |
| 157 | + |
| 158 | +See [`docs/reproducing.md`](docs/reproducing.md) for storage-specific guidance. |
| 159 | + |
| 160 | +--- |
| 161 | + |
| 162 | +## Acknowledgements |
| 163 | + |
| 164 | +This benchmark draws on prior work from: |
| 165 | + |
| 166 | +- [Expensify](https://use.expensify.com/blog/scaling-sqlite-to-4m-qps-on-a-single-server) — SQLite at 4M QPS |
| 167 | +- [Ben Johnson / Litestream](https://fly.io/blog/all-in-on-sqlite-litestream/) — WAL semantics in production |
| 168 | +- [Stephen Margheim / Fractaled Mind](https://fractaledmind.com/2024/04/15/sqlite-on-rails-the-how-and-why-of-optimal-performance/) — Rails 8 IMMEDIATE transactions |
| 169 | +- [Evan Schwartz](https://emschwartz.me/psa-your-sqlite-connection-pool-might-be-ruining-your-write-performance/) — Connection-pool write contention |
| 170 | +- [Shivek Khurana](https://shivekkhurana.com/blog/sqlite-in-production/) — WAL vs DELETE benchmarks |
| 171 | +- [Forward Email](https://forwardemail.net/en/blog/docs/sqlite-performance-optimization-pragma-chacha20-production-guide) — PRAGMA deep dive |
| 172 | + |
| 173 | +Full references in [ARTICLE.md](ARTICLE.md). |
| 174 | + |
| 175 | +--- |
| 176 | + |
| 177 | +## License |
| 178 | + |
| 179 | +MIT — see [LICENSE](LICENSE). |
| 180 | + |
| 181 | +## Contributing |
| 182 | + |
| 183 | +Issues and PRs welcome. Particularly interested in: |
| 184 | +- Results on other hardware tiers (cloud, ARM, spinning disk) |
| 185 | +- Other ORMs (Peewee, Tortoise, SQLModel) — same benchmark harness, different paths |
| 186 | +- Other databases (DuckDB, Postgres) — would be a natural extension |
0 commit comments