This file provides guidance to AI coding agents working with code in this repository.
A benchmark suite comparing Python ORMs (Django, peewee, SQLAlchemy and SQLModel sync and async, SQLObject, Tortoise ORM, Piccolo, ormar) on SQLite, PostgreSQL and MySQL, plus the embedded object database ObjectBox (SQLite runs only).
There is no test suite; the "tests" are benchmark operations.
The focus is on embedded databases: README.md holds the published SQLite/ObjectBox results tables and charts; the older PostgreSQL/MySQL results are in postgres-mysql.md.
uv venv --python 3.14 && source .venv/bin/activate
uv pip install --no-deps --index-url https://test.pypi.org/simple/ objectbox==5.0.0b1 objectbox-api==5.0.0b1 objectbox-clib==5.3.2
uv pip install --no-sources -r pyproject.tomlobjectbox==5.0.0b1 is a beta that is only on TestPyPI, so it's installed first, on its own: with --no-deps and TestPyPI as the only index, so no other package comes from TestPyPI; the second command then finds it installed. Since 5.0, objectbox is a metapackage: the Python code is in objectbox-api, the C library in objectbox-clib. Upgrading a venv that has an older objectbox (4.x, or a local 5.0.0b1 build that still contains the code) in place breaks import objectbox, so uv pip uninstall objectbox first. Its experimental codegen for puts and reads is on by default. Expected sha256: objectbox-5.0.0b1-py3-none-any.whl aa787fd532cd342dc99ddb0ab2373358afc3bdc964629f647a38a745d7b9d006, objectbox_api-5.0.0b1-py3-none-any.whl 95a908d42e5f6c7babb3183dc2abdbf9f493e056f894ef9d032cf942f1e9f797, objectbox_clib-5.3.2-py3-none-manylinux_2_28_x86_64.whl fd585a88bbb473bb976e53c3a40afe8aad88e1c1eff61b1c7945a6dd8a693dc9 (same libobjectbox.so as the earlier local builds).
The published SQLite results (raw-results/2026-10-18/) were measured with this TestPyPI release. The results before them (raw-results/2026-10-13/) used local builds of objectbox-python branch fast-flatbuffers-builder @ 0ac38fa, which no longer run the tests: the release renamed Query.set_parameter_int() to set_parameter() (used by D/E).
Use Python 3.14 to match the published results. Without --python, uv picks whatever interpreter it already has, which may be older. SQLite is the version bundled with the Python build (3.53.1 with uv's CPython 3.14.6), not a pip package; smoke.py prints the version in use. pydantic is held below 2.14 by ormar's version constraint.
uv pip install -e . (and so make deps) fails: hatchling finds no package to build. --no-sources skips the [tool.uv.sources] entry that points tortoise-orm at a local macOS path (/Users/abondar/...).
Lint/format (black line length 100, flake8, isort, bandit; applies to benchmarks/):
make style # isort + black
make check # black --check, flake8, banditRun benchmarks. Shell scripts read their config from env vars: DBTYPE (sqlite (default) | postgres | mysql), PASSWORD, PGPORT, MYPORT, TEST (1|2|3), ITERATIONS, BATCH_SIZE (objects per B/C insert, default 10000), LARGE_TABLE (0 (default) | 1, see below), CONCURRENTS (async ORMs, default 10), UVLOOP, OBX_CODEGEN (ObjectBox experimental codegen, 1 (default) | 0; with 0 the results are labeled "ObjectBox (no codegen)"), OBX_CODEGEN_CACHE (ObjectBox: directory to cache the compiled codegen in across processes, off by default; results are labeled "ObjectBox (codegen cache)"), WARMUP (0 (default) | 1, SQLite only, see below).
cd benchmarks && sh bench.sh # all ORMs, Tests 1-3, 100 iterations ("full" = 1000, "extra" = 10000)
cd benchmarks && sh bench_all.sh # sqlite + postgres + mysql
make benchmark_sqlite | benchmark_postgres | benchmark_mysqlSmoke test (SQLite only; there is no other test suite). It runs each ORM in bench.sh for Tests 1-3 and fails when an expected Rows/sec line is missing or error output appears. Don't rely on exit codes: the sync bench.sh scripts keep going after a failing step.
.venv/bin/python benchmarks/smoke.py # all ORMs, tests 1-3, 100 iterations (~2 min)
.venv/bin/python benchmarks/smoke.py -t 1 -v peewee # one ORM/test, print output of failuresWarm-up mode (WARMUP=1, SQLite only; exits with an error for other DB types): benchmarks/warmup.py runs each operation once untimed (output discarded), restores the DB to the state before it (SQLite backup API; in WAL mode (peewee, Tortoise ORM) it then checkpoints the WAL and starts a new one with a no-op write, so the measured run's commit neither runs the auto-checkpoint for the restored pages nor syncs a new WAL header, as in a cold run; ObjectBox: in-memory copy of the store files, written back and fsynced, then models.reopen()), then runs and times it in the same process. Sync bench.sh scripts run python ../warmup.py <module> instead of python -m <module> through their run() function; async bench.py scripts wrap each test in await warm(test_x.runtest, loopstr). Without WARMUP=1 both paths behave as before ("cold"). WARMUP_CHECK=1 prints to stderr, per operation, whether row counts and highest IDs after the measured run match the warm-up run. smoke.py -w runs the smoke test in warm-up mode. Reopening an ObjectBox store re-syncs the model, which drops the compiled codegen, so reopen() carries over the binding internals box._entity._fast_builder/_codegen_builder; a wheel that renames them makes warm ObjectBox runs fail with an AttributeError, and one that keeps the codegen elsewhere would recompile it inside the timed run. PG/MySQL would need a restore of their own (e.g. TRUNCATE … RESTART IDENTITY).
Run a single ORM / single test model:
cd benchmarks && TEST=2 ITERATIONS=100 DBTYPE=sqlite sh tortoise/bench.shRun a single operation with a sync ORM: cd benchmarks/peewee && ../db.sh && python -m set_up && python -m test_d. Async ORMs run every operation in one process through bench.py, so to run a single op you edit bench.py.
DB setup (benchmarks/db.sh): SQLite uses /tmp/db.sqlite3. Postgres needs psql reaching 127.0.0.1 as user postgres. MySQL expects a Docker container named bench-mysql. Each run drops and recreates database tbench.
- One directory per ORM under
benchmarks/<orm>/, each withmodels.py,bench.shandtest_a.py…test_k.py, one file per operation (A = single insert … K = delete; the operation table is in README.md). G/H (dict/tuple projections) are measured but not presented:generate_tables.py/generate_charts.pyleave them out of the tables, charts and geometric means (OP_ORDER). Some ORMs leave out operations they don't support (e.g. SQLObject has no C/G/H, Pony has no C). - Test model selection: every
models.pyreadsTESTfrom the env at import time and defines a differentJournalmodel for it: 1 = simple 4-field model, 2 = self-FK + M2M, 3 = wide model with 32+ fields. A model change has to be made in all ORM directories so the comparison stays fair. - Sync vs async runners: sync ORMs (django, peewee, sqlobject, pony, sqlalchemy, sqlmodel_sync, objectbox) run each
test_*.pyas a separate process frombench.sh(run test_x:python -m test_x, or../warmup.pywithWARMUP=1), after aset_up.py/migrate step. Async ORMs (tortoise, piccolo, ormar, sqlalchemy_async, sqlmodel) have abench.pythat builds the DB URL from env, creates the schema and awaits each test module'sruntest(loopstr). Each async test fans out acrossCONCURRENTScoroutines withasyncio.gather. - Timed section: only persisting is timed. Insert tests (A/B/C) create the unsaved objects before
start = time.time()and the timed loop only saves them (e.g.obj.save(force_insert=True)instead ofobjects.create(...)); update tests (I/J) choose the new values before the timer. SQLObject inserts in its constructor, so only its field values are prepared; peewee's C prepares row tuples. Async tests build one list per concurrent task insideruntest()(not at module level), so the warm-up run gets fresh objects. Right before the timer, every test callsgc.collect()andgc.freeze(), so leftovers from the preparation (or the warm-up run) aren't collected while timed and existing objects stay out of collections. Keep this when adding an ORM or changing a test. - Batch size and table size: B and C insert
BATCH_SIZEobjects (default 10000; all other operations useITERATIONS), so their transaction measures insert throughput instead of mostly one durable commit (~8 ms). D-K run on the whole table, so after B and C eachbench.sh/bench.pycalls an untimed trim step (benchmarks/trim.py, ObjectBoxobjectbox/trim.py; SQLite only) that deletes the newestBATCH_SIZE - ITERATIONSrows: D-K see ~300 rows, as before. Django's table issimple_journal<TEST>, so itsbench.shpasses that name.LARGE_TABLE=1skips the trim: D-K then run on ~20,100 rows; these results are published separately ("large table"). - Bulk insert (C): each ORM's fastest bulk path. SQLAlchemy/SQLModel (sync and async) use
bulk_save_objects();session.add_all()inserts one by one, like B. Only Django, Piccolo and ObjectBox set the new IDs on the objects; peewee inserts row tuples. Piccolo and ormar split their bulk insert into chunks inside one transaction: SQLite allows at most 32,766 variables per statement (Test 3 has ~36 columns). - Async ORMs on SQLite: each async test runs
CONCURRENTStasks with one transaction each; SA ORM async, SQLModel async, Piccolo and ormar open one SQLite connection per task, which wait for the write lock. Their SQLite connections use a 60 s busy timeout (default 5 s), otherwise large transactions fail withdatabase is locked. Don't read rows inside a write transaction there (e.g.session.merge(obj)withoutload=False): a transaction that read first can't wait for the write lock: if another connection committed in between, SQLite fails it at once withdatabase is locked. - Output contract: every test prints exactly one line,
"<ORM Name>, <Letter>: Rows/sec: <float>".benchmarks/present.py(stdin → table with geometric mean and best ORM),generate_tables.pyandgenerate_charts.pyall parse that format with regexes, so keep it exact. The ORM display name must also match theORM_ORDER/ORM_COLORSlists in the generator scripts. - Which ORMs run:
benchmarks/bench.shlists the ORMs explicitly for each test.pony/exists but is not in the list. Syncsqlalchemy/prints as "SA ORM",sqlalchemy_async/as "SA ORM async"; likewisesqlmodel_sync/as "SQLModel" andsqlmodel/as "SQLModel async". To add an ORM, create its directory, add it to all three test blocks inbench.sh, and add it to the ORM lists ingenerate_tables.py/generate_charts.pyand to README.md. - ObjectBox (
benchmarks/objectbox/, sync layout) is not a SQL ORM: itsbench.shexits immediately forDBTYPE=postgres/mysql, and it doesn't usedb.sh.set_up.pywipes/tmp/objectbox-bench, which also holdsobjectbox-model.json, so eachTESTstarts with a fresh schema. Differences from the SQL ORMs: no relations in the Python binding (Test 2 uses indexed id properties), no NULL or per-property defaults (Test 3:new_journal()sets defaults, "nullable" columns stay unset), decimals stored as strings, JSON as a JSON string via theput/find/get/get_allhelpers inmodels.py(Flex decodes ~40x slower thanjson.loads), no G/H (no projection API), and J writes the whole object. - Results pipeline:
bench.shwritesbenchmarks/outfile{1,2,3}and appends tobenchmarks/results.generate_tables.py(markdown tables for README.md) andgenerate_charts.py(PNGs inimages/) read from/tmp/{pg,mysql,sqlite}_outfile{1,2,3}, so the outfiles have to be copied/renamed there for each DB type before you run them.raw-results/<date>/keeps the raw outfiles and medians behind the published tables (.gitignoreignoresoutfile*except there).