Skip to content

Commit f3c6b0d

Browse files
committed
feat: initial release
11-config × 10M sweep + ORM-vs-raw benchmark harness for SQLite write throughput. Includes long-form ARTICLE.md with industry comparison across 20+ references (Expensify, Litestream, DHH, Forward Email, etc.) and our actual benchmark data (10M + 50M rows) under results/sample/. Findings: ORM ceiling ~3,800 r/s across all 11 configs. Raw executemany hits 87,893 r/s at 10M (23.8x speedup) and 65,742 r/s at 50M (17.9x). PRAGMA tuning irrelevant for ORM workloads. Chunk size scales p99 66x linearly with no throughput improvement. Zero errors with QueuePool.
0 parents  commit f3c6b0d

27 files changed

Lines changed: 5839 additions & 0 deletions

.github/workflows/ci.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
smoke:
11+
name: Smoke test
12+
runs-on: ubuntu-latest
13+
strategy:
14+
matrix:
15+
python: ["3.11", "3.12"]
16+
steps:
17+
- uses: actions/checkout@v4
18+
19+
- name: Set up Python ${{ matrix.python }}
20+
uses: actions/setup-python@v5
21+
with:
22+
python-version: ${{ matrix.python }}
23+
cache: pip
24+
25+
- name: Install
26+
run: |
27+
python -m pip install --upgrade pip
28+
pip install -e .
29+
30+
- name: Import check
31+
run: |
32+
python -c "from sqlite_bench import scale_benchmark, before_after, paths, engine, schema, configs, data_generator, monitor, results, report"
33+
34+
- name: Smoke test before_after (10K rows)
35+
run: |
36+
python -m sqlite_bench.before_after --scales 10K --mode both --output-dir /tmp/smoke
37+
38+
- name: Smoke test scale_benchmark (3 presets × 5K)
39+
run: |
40+
python -m sqlite_bench.scale_benchmark --scales 5K --configs presets --output-dir /tmp/smoke_scale
41+
42+
- name: Verify results JSON
43+
run: |
44+
python -c "import json; d=json.load(open('/tmp/smoke/results.json')); assert len(d['results'])>=2"
45+
python -c "import json; d=json.load(open('/tmp/smoke_scale/results.json')); assert len(d['results'])>=3"

.gitignore

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
__pycache__/
2+
*.py[cod]
3+
*$py.class
4+
*.so
5+
.Python
6+
.venv/
7+
venv/
8+
env/
9+
.env
10+
.env.local
11+
12+
# Benchmark artifacts
13+
*.db
14+
*.db-shm
15+
*.db-wal
16+
*.sqlite
17+
*.sqlite-shm
18+
*.sqlite-wal
19+
results/scale/
20+
results/before_after/
21+
results/*.log
22+
!results/sample/
23+
24+
# IDE
25+
.vscode/
26+
.idea/
27+
*.swp
28+
*.swo
29+
.DS_Store
30+
31+
# Build
32+
build/
33+
dist/
34+
*.egg-info/
35+
.pytest_cache/
36+
.ruff_cache/
37+
.mypy_cache/

ARTICLE.md

Lines changed: 495 additions & 0 deletions
Large diffs are not rendered by default.

CONTRIBUTING.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Contributing
2+
3+
Issues and PRs welcome.
4+
5+
## Particularly interested in
6+
7+
- **Results on other hardware tiers** — cloud VMs, ARM (M1, Graviton), spinning disk, eMMC. Open an issue with your `results.json` + system info per [docs/reproducing.md](docs/reproducing.md).
8+
- **Other ORMs** — Peewee, Tortoise, SQLModel. Same benchmark harness, different `paths.py` implementation. PR welcome.
9+
- **Other databases** — DuckDB, Postgres (libpq vs psycopg vs SQLAlchemy). Natural extension.
10+
- **Bug fixes** — particularly around result aggregation, percentile math, and the edge case where checkpoints fall inside a single chunk.
11+
12+
## Setup
13+
14+
```bash
15+
git clone https://github.com/TanayK07/sqlite-orm-bench.git
16+
cd sqlite-orm-bench
17+
pip install -e ".[dev]"
18+
```
19+
20+
## Code style
21+
22+
- Black with line-length 100
23+
- Ruff for linting
24+
- Python 3.11+ syntax (`str | None`, not `Optional[str]`)
25+
- PEP 604 unions everywhere
26+
27+
```bash
28+
black sqlite_bench/
29+
ruff check sqlite_bench/
30+
```
31+
32+
## Running tests
33+
34+
```bash
35+
# Fast smoke test
36+
python -m sqlite_bench.before_after --scales 10K --mode both
37+
38+
# Full validation (≈ 5 hours, don't do this on CI)
39+
python -m sqlite_bench.scale_benchmark --scales 3M,5M,10M --configs all
40+
```
41+
42+
## PR checklist
43+
44+
- [ ] Smoke test passes locally
45+
- [ ] No new wall-finishing or project-specific references
46+
- [ ] Type hints use PEP 604 syntax
47+
- [ ] No `print()` outside of CLI entry points (use the existing checkpoint printing helpers)
48+
- [ ] If adding a new config: include rationale in PR description

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Tanay Kedia
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
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+
[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
8+
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
9+
[![Read the article](https://img.shields.io/badge/article-ARTICLE.md-green.svg)](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

docs/findings.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Detailed Findings
2+
3+
See [ARTICLE.md](../ARTICLE.md) for the full long-form writeup with industry comparison.
4+
5+
## Quick Reference
6+
7+
### Five Key Conclusions
8+
9+
1. **The ORM Is the Bottleneck, Not SQLite** — 11 configs × 10M rows, never exceeded 3,821 r/s. Raw `executemany` hits 87,893 r/s.
10+
2. **PRAGMA Tuning Is Irrelevant for ORM Workloads**`sync=OFF`, 64MB cache, 256MB mmap, 8-conn pool: all within 26%.
11+
3. **Chunk Size Controls Latency, Not Throughput** — p99 scales 66× (313ms → 20,508ms). Throughput drops only 20%.
12+
4. **Baseline Config Wins**`sync=NORMAL`, default cache, `pool=5`, `chunk=5000`. No exotic PRAGMAs needed.
13+
5. **Raw SQL Degrades Gracefully at Scale** — 87,893 r/s @ 10M → 65,742 r/s @ 50M (25% drop). I/O scaling curve.
14+
6. **QueuePool Eliminates Concurrency Errors** — Zero errors across 110M rows.
15+
16+
### When to Bypass Your ORM
17+
18+
| Scenario | ORM | Raw SQL |
19+
|----------|-----|---------|
20+
| < 1K rows | Good enough | Premature optimization |
21+
| 10K–1M rows | 2–5 min/M | 7 sec/M |
22+
| 1M–100M rows | 45 min/10M | 1.9 min/10M |
23+
| Sustained > 1K r/s | Ceiling at 3.8K | Headroom to 88K |
24+
| Upsert-heavy | Slow | 18× faster |
25+
26+
### Surprising Findings From the Literature
27+
28+
- **`temp_store=MEMORY` can be slower than disk** (Forward Email)
29+
- **SQLite's default exponential backoff is bad** — uniform 1ms is better (Margheim)
30+
- **WAL is slower than DELETE at low concurrency** — 43% slower at 1 worker (Khurana)
31+
- **50-connection pool is 23× slower than single writer** (Schwartz)
32+
- **Node.js version matters more than SQLite config** — v24 57% slower than v20 (Forward Email)
33+
34+
See [ARTICLE.md](../ARTICLE.md) sections "Surprising Findings From the Literature" and "How Our Numbers Compare to the Industry".

0 commit comments

Comments
 (0)