bench.py runs identical workloads against this library (yara-orm) and
eight other Python ORMs, each in its own table on the same database:
| ORM | model | driver (PostgreSQL) |
|---|---|---|
| yara-orm | async | Rust engine (tokio-postgres) |
| Tortoise ORM | async | asyncpg |
| SQLAlchemy 2.0 | async | asyncpg |
| Pony ORM | sync | psycopg2 |
| Django ORM | sync | psycopg/psycopg2 |
| Peewee | sync | psycopg2 |
| SQLObject | sync | psycopg2 |
| Ormar | async | asyncpg |
| Piccolo | async | asyncpg (no MySQL backend) |
Any ORM that isn't installed — or can't serve the chosen backend (e.g. Piccolo on
MySQL) — is skipped and reported as -, so the suite runs with whatever subset is
present.
Pony's query decompiler does not support Python 3.13+, so the full run needs
Python ≤ 3.12. The others run on any supported version (a missing/incompatible
ORM is simply reported as -).
# full suite (Python 3.12)
python3.12 -m venv .venv312
.venv312/bin/pip install -U maturin \
tortoise-orm "sqlalchemy[asyncio]" asyncpg pony psycopg2-binary \
django peewee sqlobject ormar piccolo
VIRTUAL_ENV=$PWD/.venv312 .venv312/bin/maturin develop --release
ORM_TEST_DB=postgres://USER@localhost/orm_demo .venv312/bin/python benchmarks/bench.pyEnv knobs: BENCH_N (bulk rows, default 5000), BENCH_S (single-insert rows,
500), BENCH_GETS (pk lookups, 1000), BENCH_REPEAT (runs per ORM, median
reported, 5).
Set BENCH_BACKEND=sqlite to run the same workload on SQLite (each ORM
gets its own file in BENCH_SQLITE_DIR, default /tmp). Needs aiosqlite
for the async ORMs (Tortoise, SQLAlchemy, Ormar, Piccolo):
.venv312/bin/pip install -U aiosqlite
BENCH_BACKEND=sqlite .venv312/bin/python benchmarks/bench.pySet BENCH_BACKEND=mysql to run the same workload on MySQL
(ORM_TEST_MYSQL, default mysql://root:root@localhost:3306/orm_demo).
Competitor drivers: asyncmy (Tortoise), aiomysql (SQLAlchemy, Ormar) and
pymysql + cryptography (Pony, Django, Peewee, SQLObject; MySQL 8's
caching_sha2_password needs cryptography) — any missing driver is simply
reported as -. Piccolo has no MySQL backend and is reported as - here:
.venv312/bin/pip install -U asyncmy aiomysql pymysql cryptography
BENCH_BACKEND=mysql .venv312/bin/python benchmarks/bench.pySet BENCH_BACKEND=mariadb to run the same workload on MariaDB
(ORM_TEST_MARIADB, default mysql://root:root@localhost:3307/orm_demo). Every
competitor reaches MariaDB through its MySQL driver (same wire protocol), so the
driver set is identical to the MySQL run; yara-orm auto-detects the server and
switches to its MariaDB dialect (RETURNING, VALUES() upsert). Piccolo has no
MySQL backend and is reported as -:
.venv312/bin/pip install -U asyncmy aiomysql pymysql cryptography
BENCH_BACKEND=mariadb .venv312/bin/python benchmarks/bench.py- Each ORM gets its own table and the same workload and data.
- Every operation is timed
BENCH_REPEATtimes and the median is reported, so warm steady-state (driver + prepared-statement caches hot) dominates over cold-start noise. get_by_pkissues the same random pk sequence to every ORM.- Caveats — this is throughput-oriented, not a micro-benchmark:
- sync (Pony, Django, Peewee, SQLObject) vs async (Tortoise, SQLAlchemy, Ormar,
Piccolo,
yara-orm) have different concurrency models; - Pony opens a transaction per
get(its design) and has no SQL-level bulkUPDATE, so its update path mutates objects in a loop; - SQLAlchemy
get_by_pkuses a fresh session per lookup (no identity-map reuse), matching the stateless-handler pattern the others use; - SQLObject runs with
cache=falseso every.get()hits the database, and has no bulkINSERT— itsbulk_insertis one row per statement wrapped in a single transaction; - Ormar exposes no
GROUP BY/annotate API and Piccolo noHAVINGclause, so theirgroup_bycells read-/ run unfiltered respectively; - feature sets differ. Treat the numbers as indicative.
- sync (Pony, Django, Peewee, SQLObject) vs async (Tortoise, SQLAlchemy, Ormar,
Piccolo,
Nine ORMs, all measured together in one run per backend (Apple Silicon, Python 3.12, N=5000, median of 5). Piccolo has no MySQL backend, so it is absent from the MySQL and MariaDB tables.
PostgreSQL 18 (ms, lower is better):
| operation | yara-orm | tortoise | sqlalchemy | pony | django | peewee | sqlobject | ormar | piccolo |
|---|---|---|---|---|---|---|---|---|---|
| bulk_insert | 15.2 | 25.2 | 80.8 | 223.8 | 40.5 | 51.1 | 513.3 | 227.5 | 100.7 |
| single_insert | 34.4 | 84.1 | 158.7 | 60.8 | 42.9 | 46.6 | 53.3 | 169.1 | 92.3 |
| fetch_all | 3.6 | 17.3 | 31.6 | 34.4 | 9.1 | 11.9 | 27.8 | 56.6 | 4.4 |
| count | 0.3 | 0.5 | 1.0 | 0.4 | 0.5 | 0.3 | 0.3 | 6.0 | 0.4 |
| group_by | 0.8 | 1.1 | 1.6 | 2.3 | 1.0 | 0.8 | 0.6 | - | 1.0 |
| filter | 2.2 | 9.1 | 8.4 | 17.5 | 5.3 | 6.8 | 9.3 | 21.2 | 2.6 |
| get_by_pk | 64.1 | 205.9 | 310.4 | 86.0 | 121.1 | 115.6 | 23.1 | 336.9 | 201.6 |
| update | 3.5 | 3.9 | 4.1 | 121.1 | 3.5 | 3.4 | 3.3 | 12.8 | 3.6 |
| delete | 0.7 | 0.9 | 1.1 | 95.5 | 0.8 | 0.7 | 0.6 | 2.2 | 0.8 |
The group_by op is a GROUP BY … COUNT/SUM … HAVING aggregate over the rows
(Ormar has no GROUP BY API, hence -).
Speedup vs yara-orm (competitor_time / yara_orm_time; >1 means yara-orm faster):
| operation | tortoise | sqlalchemy | pony | django | peewee | sqlobject | ormar | piccolo |
|---|---|---|---|---|---|---|---|---|
| bulk_insert | 1.7x | 5.3x | 14.7x | 2.7x | 3.4x | 33.8x | 15.0x | 6.6x |
| single_insert | 2.4x | 4.6x | 1.8x | 1.2x | 1.4x | 1.6x | 4.9x | 2.7x |
| fetch_all | 4.8x | 8.7x | 9.5x | 2.5x | 3.3x | 7.7x | 15.6x | 1.2x |
| count | 2.0x | 3.6x | 1.5x | 1.8x | 1.0x | 1.2x | 22.5x | 1.6x |
| group_by | 1.4x | 1.9x | 2.8x | 1.3x | 1.0x | 0.7x | - | 1.2x |
| filter | 4.1x | 3.8x | 7.9x | 2.4x | 3.1x | 4.2x | 9.6x | 1.2x |
| get_by_pk | 3.2x | 4.8x | 1.3x | 1.9x | 1.8x | 0.4x | 5.3x | 3.1x |
| update | 1.1x | 1.2x | 35.0x | 1.0x | 1.0x | 0.9x | 3.7x | 1.0x |
| delete | 1.2x | 1.5x | 129.6x | 1.1x | 0.9x | 0.9x | 2.9x | 1.1x |
yara-orm is fastest or tied on every operation; the only place any ORM edges
ahead is SQLObject on get_by_pk (0.4× — 23.1 vs 64.1 ms), where its lean
in-process sync active-record avoids the async event-loop hop on single-row point
reads (the same latency-bound floor that keeps Pony close on get_by_pk).
Everything throughput-shaped is far ahead (bulk_insert up to 34×, fetch_all
up to 16×, delete 130× vs Pony's row-by-row loop).
The grouped-bar charts shown in the README and docs are rendered from these
numbers by plot_benchmarks.py (the values are embedded in the script, so it
needs no database — just pip install matplotlib):
python benchmarks/plot_benchmarks.py
# writes docs/assets/benchmark-{postgres,mysql,mariadb,sqlite}.pngIf you re-run bench.py, update the tables here and the BACKENDS dict in
plot_benchmarks.py so the charts stay in sync.
BENCH_BACKEND=mysql, MySQL 8.4 (Docker), Apple Silicon, Python 3.12, N=5000,
median of 5 (ms, lower is better). Tortoise runs over asyncmy, SQLAlchemy/Ormar
over aiomysql, the sync ORMs over pymysql. Piccolo has no MySQL backend:
| operation | yara-orm | tortoise | sqlalchemy | pony | django | peewee | sqlobject | ormar |
|---|---|---|---|---|---|---|---|---|
| bulk_insert | 46.8 | 48.2 | 596.1 | 443.5 | 100.0 | 82.1 | 1076.6 | 212.3 |
| single_insert | 638.7 | 660.6 | 985.1 | 800.6 | 783.4 | 743.5 | 773.8 | 1062.2 |
| fetch_all | 7.5 | 33.8 | 44.3 | 47.7 | 28.7 | 28.5 | 42.0 | 73.0 |
| count | 0.5 | 0.8 | 1.2 | 0.9 | 0.9 | 0.8 | 0.7 | 4.2 |
| group_by | 1.3 | 1.4 | 2.0 | 2.5 | 1.5 | 1.2 | 1.0 | - |
| filter | 3.2 | 17.5 | 15.5 | 24.6 | 15.1 | 14.9 | 16.8 | 31.3 |
| get_by_pk | 122.0 | 226.1 | 524.2 | 315.4 | 214.0 | 208.0 | 64.6 | 924.3 |
| update | 6.8 | 7.4 | 9.9 | 232.4 | 7.7 | 9.5 | 7.4 | 11.7 |
| delete | 4.9 | 4.8 | 5.6 | 207.0 | 5.8 | 4.8 | 6.6 | 6.1 |
yara-orm is fastest or tied on every operation (fetch_all 3.8–9.7×, filter
4.7–9.8×, get_by_pk 1.7–7.6× vs the competitors) — except SQLObject's leaner
get_by_pk (0.5×) and the sub-millisecond group_by, where peewee/SQLObject
edge us (0.8–0.9×). The two latency-bound ops carry the Docker-network round
trip, and single_insert (~0.6–1.1 s across the board) is dominated by InnoDB's
per-commit fsync — a cost every ORM pays equally.
BENCH_BACKEND=mariadb, MariaDB 11 (Docker), Apple Silicon, Python 3.12,
N=5000, median of 5 (ms, lower is better). Every competitor connects through its
MySQL driver; yara-orm auto-detects MariaDB and uses its RETURNING dialect.
Piccolo has no MySQL backend, so it is absent here:
| operation | yara-orm | tortoise | sqlalchemy | pony | django | peewee | sqlobject | ormar |
|---|---|---|---|---|---|---|---|---|
| bulk_insert | 25.6 | 38.4 | 101.2 | 473.5 | 100.8 | 59.6 | 1247.3 | 208.4 |
| single_insert | 311.9 | 345.2 | 476.6 | 403.7 | 296.3 | 299.0 | 345.6 | 573.5 |
| fetch_all | 5.8 | 36.1 | 43.2 | 48.1 | 32.1 | 31.0 | 42.7 | 71.6 |
| count | 0.4 | 0.8 | 1.3 | 0.8 | 0.9 | 0.7 | 0.6 | 5.8 |
| group_by | 1.3 | 1.3 | 2.1 | 2.2 | 1.8 | 1.3 | 0.9 | - |
| filter | 3.3 | 17.7 | 16.2 | 24.8 | 15.4 | 14.6 | 17.3 | 50.0 |
| get_by_pk | 120.8 | 224.8 | 541.4 | 310.9 | 217.8 | 210.7 | 64.8 | 914.8 |
| update | 3.8 | 3.3 | 6.6 | 264.7 | 4.5 | 4.3 | 4.2 | 7.3 |
| delete | 2.7 | 2.8 | 3.2 | 252.1 | 3.1 | 2.8 | 3.0 | 3.7 |
yara-orm leads or ties every operation except SQLObject's leaner get_by_pk
(0.5×) and the sub-millisecond group_by (SQLObject 0.7× via raw SQL).
single_insert, update and delete are near ties across every ORM because
they are database-bound, not client-bound: single inserts are paced by
MariaDB's per-commit disk fsync (high run-to-run variance — it swings ±40%
between runs), and update/delete are one server-side set statement each, so
there's no client-side row marshaling for the Rust hot path to accelerate. It
still wins the throughput ops decisively (fetch_all 5.4–12.4×, filter
4.4–15.2×, bulk_insert up to 49× vs SQLObject's row-by-row inserts). MariaDB's
single_insert (~310 ms) is notably faster than MySQL 8's (~640 ms) here — a
lighter default commit path.
BENCH_BACKEND=sqlite, Python 3.12, N=5000, median of 5 (ms, lower is better).
SQLite is in-process, so bench.py uses its recommended sync_fast_path=1
config (statements run synchronously on the calling thread — an embedded
database has no I/O to overlap, so the async bridge is pure overhead):
| operation | yara-orm | tortoise | sqlalchemy | pony | django | peewee | sqlobject | ormar | piccolo |
|---|---|---|---|---|---|---|---|---|---|
| bulk_insert | 7.5 | 14.1 | 612.6 | 50.8 | 57.3 | 28.2 | 221.3 | 160.7 | 75.7 |
| single_insert | 14.6 | 30.1 | 234.4 | 111.7 | 124.8 | 106.9 | 132.1 | 315.5 | 247.3 |
| fetch_all | 3.5 | 39.7 | 29.4 | 52.2 | 15.8 | 12.6 | 60.5 | 54.9 | 9.1 |
| count | 0.0 | 0.3 | 0.7 | 0.2 | 0.2 | 0.1 | 0.1 | 1.6 | 0.5 |
| group_by | 0.5 | 0.7 | 1.4 | 1.6 | 0.9 | 0.6 | 0.5 | - | 1.0 |
| filter | 2.0 | 20.1 | 7.6 | 25.7 | 8.6 | 7.0 | 17.7 | 42.7 | 5.0 |
| get_by_pk | 11.9 | 86.0 | 332.9 | 31.0 | 82.9 | 76.4 | 13.3 | 510.4 | 368.3 |
| update | 0.5 | 0.6 | 1.9 | 42.8 | 1.2 | 1.2 | 1.2 | 1.7 | 1.6 |
| delete | 0.3 | 0.4 | 1.2 | 35.3 | 0.8 | 0.7 | 0.8 | 1.2 | 1.1 |
yara-orm is fastest or tied on every operation (SQLObject's hand-written
raw-SQL aggregate, bypassing its ORM entirely, ties the sub-millisecond
group_by). With the fast path it wins the point reads it trailed on under the
default async bridge — get_by_pk 1.1× vs SQLObject and 2.6× vs Pony —
and stays far ahead on throughput (bulk_insert 1.9–82×, fetch_all 2.6–17×,
filter 2.5–21×, single_insert 2.1× vs Tortoise). On the default async
path (no fast path), the per-statement asyncio bridge costs tens of µs on
sequential point reads, so SQLObject's lean sync active-record leads get_by_pk
there instead; sqlite://...?sync_fast_path=1 removes that bridge (~7× faster
point queries).
- Rust hot path — parameter binding and row decoding happen in compiled code; the async bridge (PyO3 + tokio) keeps the event loop free.
- Positional row decoding — for SELECTs the engine returns column values with no per-row column-name allocation and no dict; Python fills instances by index using a precomputed decode plan that skips no-op conversions.
- Compiled-SQL caching — the SELECT column list, single-row INSERT and a
fast-path simple
get()are built once per model and reused, andprepare_cachedon each pooled connection skips re-parse/plan. - Connection pooling — deadpool keeps warm connections, so steady-state latency excludes connect cost.
- Tight parameter extraction — the
FromPyObjectpath checks the common scalar types first, minimising per-value work on large binds.
For pure projections, values_list() / values() select only the requested
columns and skip model construction (~1.7–2.2× faster than full fetch).
bench_features.py times the features the cross-ORM suite intentionally skips
because they are not comparable across ORMs and feature sets: nested-transaction
savepoints, eager loading (select_related / prefetch_related vs N+1) and
projection (values / values_list). It is yara-orm-only, so it needs no
competitor installs — just the built engine.
# SQLite (zero setup — a throwaway temp file)
make bench-features
# or directly, on either backend:
python benchmarks/bench_features.py
BENCH_BACKEND=postgres ORM_TEST_DB=postgres://USER@localhost/orm_demo \
python benchmarks/bench_features.pyEnv knobs: BENCH_AUTHORS (parent rows, default 100), BENCH_BOOKS_PER
(children each, 5), BENCH_S (transaction insert rows, 300), BENCH_REPEAT (5).
It reports each operation's time plus a feature payoff ratio (slower ÷ faster). Representative ratios (100 authors × 5 books, 300 tx rows, median of 5):
| payoff | SQLite | PostgreSQL |
|---|---|---|
select_related vs N+1 (forward FK) |
28.5× | 38.4× |
prefetch_related vs N+1 (reverse) |
9.7× | 14.5× |
| one transaction vs autocommit | 1.4× | 1.0× |
values_list vs full model fetch |
1.6× | 1.6× |
| savepoint-per-row overhead vs one tx | 2.7× | 2.8× |
Eager loading collapses the N+1 fan-out into one (select_related) or two
(prefetch_related) queries, so its payoff grows with the row count and is
largest on PostgreSQL, where each avoided query is a network round-trip. The
last row is a cost, not a win: wrapping every row in its own savepoint adds a
SAVEPOINT/RELEASE pair per insert — the price of fine-grained nested rollback.