# ARX.SDK on PostgreSQL: benchmarks

A living summary of the PostgreSQL benchmarks: how ARX compares with raw Npgsql, Dapper and EF Core.

| | |
|---|---|
| Last updated | 2026-11-11: full run of every operation on Npgsql 10.0.3 and EF Core 10, with Dapper's one-statement `unnest` insert added |
| Operations measured | Create, CreateMultiple, batch scopes, Update, UpdateMultiple, Delete, DeleteMultiple, Retrieve, RetrieveMultiple (filters, paging, joins, complex queries), Execute (procedures, functions, tables from procedures, paging), the preloaded schema, services without a catalog |
| Machine | Intel Core i7-8750H, Windows 11, .NET 10.0.14, PostgreSQL 16 (`postgres:16-alpine`) in Docker Desktop, Npgsql 10.0.3, BenchmarkDotNet 0.15.8 |
| Compared with | Raw Npgsql (hand-written, typed parameters), Dapper 2.1.89, EF Core 10 (Npgsql.EntityFrameworkCore.PostgreSQL 10.0.3; typed and property bag), binary `COPY` |
| Reports | The full run: [results/2026-11-11-full-run.md](#report-2026-11-11-full-run) (454 benchmarks, commit 9f8dc1f; the single-row Update class rerun on its own, see the report). The injection-check A/B: [results/2026-11-08-security-ab.md](#report-2026-11-08-security-ab). Earlier full run, on Npgsql 8.0.3 and EF Core 8: [results/2026-11-08-full-run.md](#report-2026-11-08-full-run). |

**In one line:** ARX is level with hand-written SQL for single-row writes and for filtered, joined and 100,000-row
reads, 1.13–1.18× it for a row by id, 1,000 rows and deep pages, and faster and lighter than EF Core almost everywhere. For many-row writes it was 0.81–1.33× the fastest hand-written Npgsql form measured here, and faster
than the usual hand-written many-row write (part of that gap is this machine's large-command stall, see the caveat
below). As a product it's a preview: all five providers are built on the shared model and pass the shared conformance
suite.

**Dapper's one-statement insert, measured.** A Dapper user who knows PostgreSQL inserts many rows with one `unnest`
statement, one array per column. At 10,000 rows that took 109 ms against ARX's 122 ms (0.90×: Dapper is faster, and the
hand-written `unnest` with ARX's own row order took 112 ms). At 1,000 rows it took 38 ms against ARX's 24 ms, but every
hand-written `unnest` form sat near this machine's ~50 ms large-command stall there, so read that row with the caveat,
not as ARX being faster. ARX spends its extra time checking and converting each row before the call.

Read the ratios, not the absolute times, which depend on the machine. Differences under about 10% on a single run are
inside the noise here. See also the [caveat on large commands](#caveat-large-commands-on-this-machine).

## Every operation at a glance (full run, 2026-11-11)

Mean times. "Usual" is the hand-written form most code uses; "best" is the fastest hand-written form found.

| Operation | **ARX** | Raw Npgsql | Dapper | EF Core | ARX vs raw |
|---|---|---|---|---|---|
| Create, one row | **1.14 ms** | 1.19 ms | 1.13–1.16 ms | 1.92–2.00 ms | level |
| CreateMultiple, 1,000 rows, ids back | **23.8 ms** | usual 64.9 ms, `unnest` 47.5 ms | one `unnest` statement: 38.3 ms; one statement per row (no ids): 1.09 s | 171 ms | not comparable on this machine: both hand-written rows sit near the large-command stall below, so this row doesn't show ARX faster (at 10,000 rows, 1.09× the best) |
| CreateMultiple, 10,000 rows | **122 ms** | usual 256 ms, best 112 ms | one `unnest` statement: 109 ms; one statement per row (no ids): 11.0 s | 1.14–1.62 s | 1.09× best; 2.1× faster than usual |
| Update, one row | **1.20 ms** | 1.19 ms | 1.29 ms | 1.45–1.89 ms | level |
| UpdateMultiple, 1,000 rows | **45.8 ms** | usual 63.6 ms, best 41.0 ms | 1.14 s | 170 ms | 1.12× best; 1.4× faster than usual |
| UpdateMultiple, 10,000 rows | **184 ms** | usual 331 ms, best 163 ms | 11.1 s | 1.71 s | 1.13× best; 1.8× faster than usual |
| Delete, one row | **1.06 ms** | 1.19 ms | 1.07 ms | 1.34–1.68 ms | level |
| DeleteMultiple, 10,000 ids | **51.2 ms** | `IN` 67.1 ms, `= ANY` 51.7 ms | 44.8 ms | 69.4 ms | level with best |
| DeleteMultiple, 100,000 ids | **143 ms** | `IN` 256 ms, `= ANY` 161 ms | 139 ms | 178 ms | 0.89× best |
| Retrieve, one row by id | **828 µs** | 709–723 µs | 761–828 µs | 1,046–1,125 µs | 1.15× |
| RetrieveMultiple, 1,000 rows | **3.96 ms** | 3.43–3.46 ms | 3.18–3.29 ms | 4.26 ms | 1.15× |
| RetrieveMultiple, 100,000 rows | **242 ms** | typed 255 ms, dictionaries 280 ms | 245–248 ms | 264 ms | 0.95× typed, 0.86× dictionaries |
| In list of 1,000 ids (of 100,000) | **6.00 ms** | 5.93 ms | 5.79 ms | 7.19 ms | 1.01× |
| Nested AND/OR | **14.1 ms** | 14.3 ms | 14.1 ms | 14.9 ms | 0.99× |
| Contains, case-insensitive | **76.0 ms** | 72.4 ms | 73.0 ms | 75.2 ms | 1.05× |
| Page of 50, page 1 | **0.88 ms** | 0.86 ms | – | 1.07 ms | 1.03× |
| Page of 50, page 1,000 + total | **25.5 ms** | 21.6 ms | – | 30.0 ms | 1.18× |
| 1,000 rows with a joined name | **2.91 ms** | 2.90 ms | 2.85 ms | – | level |
| Complex: 7-table join, page | **50.6 ms** | 52.2 ms | 48.3 ms | 47.3 ms | 0.97× |
| Complex: report, 7 aggregates | **195 ms** | 205 ms | 189 ms | 568 ms | 0.95× |
| Complex: search with an existence join | **9.35 ms** | 9.88 ms | 9.33 ms | 5.48 ms (`EXISTS`) | 0.95× |
| Execute: a procedure with OUT values | **640 µs** | 594–632 µs | 708 µs | no way to read OUT values | level |
| Execute: a function's 10,000 rows | **15.8 ms** | 17.8 ms | 15.4 ms | 14.8 ms | 0.89× |
| Execute: a procedure's two tables (cursors) | **1.60 ms** | 1.82 ms | – | – | 0.88× |
| Execute: a page of 50 of 100,000 + total, the routine paging itself | **6.5–10.4 ms** | 7.0–10.8 ms | – | – | level |

## Did the injection check cost anything?

**No measurable cost** ([security-ab](#report-2026-11-08-security-ab)). The six write classes, run on the code
without the injection check and with it (commits `fa6ba08` and `fa54ee3`), one after
the other:

- ARX's times moved by a median of +0.4%, and the code the check doesn't touch (raw, Dapper, EF Core) by +0.0%, both
  within the same −18% to +34% run-to-run spread.
- ARX's memory per call changed by at most 0.05 KB on single-row calls: allocations are exact, so this is the precise
  measure, and it says the normal path didn't change.
- Why: a valid name takes the same path as before. The check runs only on a name that has already failed validation,
  to choose ARX1017 over ARX1003.

## Reads

From the full run.

### One row and many rows

| Rows | Raw typed (ceiling) | Raw dictionaries (ARX's shape) | **ARX** | Dapper POCO / dynamic | EF Core AsNoTracking |
|---|---|---|---|---|---|
| 1 by id | 709 µs, 2.8 KB | 723 µs, 3.3 KB | **828 µs, 7.0 KB** | 828 / 761 µs | 1,046 µs, 41.7 KB |
| 100 | 0.96 ms | 1.14 ms | **1.03 ms** | 0.99 / 1.00 ms | 1.73 ms |
| 1,000 | 3.43 ms, 0.20 MB | 3.46 ms, 0.73 MB | **3.96 ms, 0.61 MB** | 3.29 / 3.18 ms | 4.26 ms, 0.42 MB |
| 10,000 | 26.4 ms | 32.8 ms | **28.8 ms** | 26.1 / 28.7 ms | 30.3 ms |
| 100,000 | 255 ms, 20 MB | 280 ms, 73 MB | **242 ms, 59 MB** | 248 / 245 ms | 264 ms, 38 MB |

- **One row:** about 0.1 ms over raw Npgsql (1.15×), 1.26× faster than EF Core; Dapper's dynamic rows were 8% faster.
- **Many rows:** 0.86–1.14× the hand-written read into dictionaries (faster from 10,000 rows), with less memory;
  0.95–1.16× the typed ceiling.
- **Memory:** an `ArxEntity` per row is about 3× a typed row, less than a dictionary per row.

### Queries

| Query | Raw Npgsql | **ARX** | Dapper | EF Core |
|---|---|---|---|---|
| In list of 1,000 ids | 5.93 ms | **6.00 ms** | 5.79 ms | 7.19 ms |
| Nested AND/OR (499 rows) | 14.3 ms | **14.1 ms** | 14.1 ms | 14.9 ms |
| Contains '99', case-insensitive | 72.4 ms | **76.0 ms** | 73.0 ms | 75.2 ms |
| Page 1 of 50 | 0.86 ms | **0.88 ms** | – | 1.07 ms |
| Page 1,000 of 50 | 11.9 ms | **13.4 ms** | – | 12.1 ms |
| Page 1 of 50 + total | 6.70 ms | **6.64 ms** | – | 10.1 ms |
| Page 1,000 of 50 + total | 21.6 ms | **25.5 ms** | – | 30.0 ms |
| 1,000 rows, LEFT JOIN for a name | 2.90 ms | **2.91 ms** (link entity), **2.93 ms** (`_lk`), **2.96 ms** (declared lookup + display name) | 2.85 ms | – |

- Every query is 0.99–1.05× raw Npgsql, except the deep pages (page 1,000: 1.13× and 1.18×, against 0.96–1.02× in the
  2026-11-08 run; not investigated yet): building the SQL, validating every name and making every value a parameter
  doesn't show next to the database's work.
- **The total is opt-in:** a page with the total took 6.6 ms against 0.9 ms without it (counting 100,000 rows). ARX
  sends both statements in one round trip; EF Core needs two. `Advanced7_PagingInDepth` shows when to leave it off.

### Complex queries

`ArxQueryExpression` in depth on seven joined tables (100,000 order lines). ARX's rows, values and order are checked
against the raw SQL by `--verify` first.

| Shape | Raw Npgsql | Dapper | EF Core | **ARX** |
|---|---|---|---|---|
| Deep join (7 tables, 17 columns, criteria in the joins, nested AND/OR across aliases, 3-table order), page 1 of 100 | 52.2 ms | 48.3 ms | 47.3 ms | **50.6 ms** |
| The same, page 20 + total | 89.3 ms | 90.2 ms | 87.5 ms | **90.5 ms** |
| The same, every matching row (3,423) | 65.6 ms, 4.1 MB | 65.5 ms, 2.7 MB | 72.5 ms, 2.8 MB | **61.5 ms, 3.3 MB** |
| Report: 7 aggregates over 5 tables, grouped by 2 linked columns | 205 ms | 189 ms | 568 ms | **195 ms** |
| Search: nested OR groups, In, NotIn, Between, Contains, IS NULL, a link with its own nested OR, Distinct, `_lk`, Top 500 | 9.88 ms | 9.33 ms | 5.48 ms | **9.35 ms** |

- **ARX is level with hand-written SQL on every shape** (0.94–1.01×, inside the noise).
- **EF Core's report took 2.8×** for the same result; not investigated.
- **EF Core's search was fastest (0.55×):** `Any()` becomes `EXISTS`, which stops at the first matching order. ARX,
  like the hand-written join, reads every matching order and removes duplicates: it has no "exists" link yet.
- The report groups by two link columns that its links also read. This run is the first since that was fixed: the
  preview.4 read engine refused it (ARX1004).

## Execute

Procedures and functions, from the full run.

| Call | Raw Npgsql | Dapper | EF Core | **ARX** |
|---|---|---|---|---|
| A procedure, 2 IN and 2 OUT values | 594–632 µs | 708 µs | – | **640 µs** |
| A function's value | 580 µs | 572 µs | 880 µs | **692 µs** |
| A function's 1,000 rows | 1.96 ms | 1.84 ms | 2.66 ms | **2.03 ms** |
| A function's 10,000 rows | 17.8 ms | 15.4 ms | 14.8 ms | **15.8 ms** |
| A procedure's two tables | 1.82 ms (3 round trips) | – | – | **1.60 ms** (2) |
| 100 calls in one transaction | 63.6 ms | – | – | **55.3 ms**, **56.0 ms** in a scope with savepoints (102.8 ms each on its own) |

Paging a page of 50 out of 100,000 rows with the total (page 1 / page 1,000), each way against raw Npgsql doing the same:

| Way | Raw | **ARX** |
|---|---|---|
| The routine pages itself | 7.0 / 10.8 ms | **6.5 / 10.4 ms** |
| ARX pages a SQL function | 23.8 / 32.2 ms | **23.9 / 32.2 ms** |
| ARX pages a PL/pgSQL function | 76.8 / 95.6 ms | **79.2 / 97.8 ms** |
| ARX pages a cursor | 10.6 / 10.2 ms | **9.8 / 10.1 ms** |

- **Level with the hand-written call** (0.87–1.08×; a function's single value 1.19×, about 0.1 ms). EF Core has no
  way to read a procedure's OUT values.
- **The way of paging matters far more than the library:** 6.5 ms when the routine pages itself, 24 ms when ARX pages a
  SQL function (the total counts every row), 79–98 ms for a PL/pgSQL function (it produces every row first).

## Preloaded schema

What preloading the schema at service setup changes (full run).

| Measured | Declared by hand | Preloaded, DataTypes | Strict |
|---|---|---|---|
| Building the schema, once at startup | 2.2 µs | 13.0 ms (KeysOnly 14.2 ms; from saved JSON 20 µs) | – |
| First read of 100 rows with an enum, new service | 2.39 ms | **0.93 ms** | – |
| 100 rows with an enum, same service | 0.92 ms | 0.89 ms | 0.91 ms |
| Create one row | 1.28 ms | 1.41 ms (KeysOnly 1.25 ms) | 1.24 ms |

- **The first read of enum columns is 2.6× faster preloaded:** no failed first attempt. Once per statement
  shape per service.
- **Steady state: no measurable difference**, Strict included (the Create rows are 1.21–1.41 ms, raw Npgsql 1.21 ms:
  inside the noise). Don't claim a preload makes calls faster: it resolves types at setup and refuses wrong values
  before the database.

## Writes

### Single record

| Operation | **ARX** | Raw Npgsql | Dapper | EF Core |
|---|---|---|---|---|
| Create (10 columns, returns the id) | **1.14 ms, 12.7 KB** | 1.19 ms, 4.2 KB | 1.13–1.16 ms | 1.92–2.00 ms, 54–62 KB |
| Create with a selection set | **1.14 ms** | 1.09 ms | – | – |
| Update by id (10 columns) | **1.20 ms, 16.5 KB** | 1.19 ms, 4.5 KB | 1.29 ms | 1.45–1.89 ms, 55–64 KB |
| Delete by id | **1.06 ms, 5.1 KB** | 1.19 ms, 2.2 KB | 1.07 ms | 1.34–1.68 ms, 41–45 KB |

All within the noise of raw Npgsql and Dapper (±0.1–0.3 ms on this machine); EF Core takes 1.2–1.8× as long.

### Many records

| Rows | Raw multi-row VALUES (usual) | Raw `unnest` (best) | **ARX** | EF Core typed |
|---|---|---|---|---|
| CreateMultiple, 10 | 1.60 ms | 1.74 ms | **1.41 ms** | 4.60 ms |
| CreateMultiple, 100 | 3.51 ms | 2.51 ms | **3.35 ms** | 30.5 ms |
| CreateMultiple, 1,000 | 64.9 ms | 47.5 ms (the stall) | **23.8 ms** | 172 ms |
| CreateMultiple, 10,000 | 256 ms | 112 ms | **122 ms** | 1,138 ms |
| UpdateMultiple, 100 | 3.74 ms | 2.42 ms | **2.92 ms** | 18.1 ms |
| UpdateMultiple, 1,000 | 63.6 ms | 41.0 ms | **45.8 ms** | 170 ms |
| UpdateMultiple, 10,000 | 331 ms | 163 ms | **184 ms** | 1,708 ms |
| DeleteMultiple, 1,000 ids | `IN` 21.4 ms | `= ANY` 4.27 ms | **4.79 ms** | 11.7 ms |
| DeleteMultiple, 100,000 ids | `IN` 256 ms | `= ANY` 161 ms | **143 ms** | 178 ms |

- **Against the usual hand-written SQL:** level at 10 rows, then 1.05–2.7× faster.
- **Against the best hand-written SQL:** 0.81–1.33× (leaving out the 1,000-row create, where the hand-written form hit
  the stall).
- **Against EF Core:** 3.3–9.3× faster for many-row creates and updates; 1.2–2.4× for DeleteMultiple (both send one
  statement).
- **Against Dapper:** `ExecuteAsync(sql, list)` runs one statement per row: 1.09 s for 1,000 creates and 11.1 s for
  10,000 updates, against ARX's 24 ms and 184 ms. Dapper's one `unnest` statement: 38.3 ms at 1,000 rows and 109 ms at
  10,000 (0.90× ARX).
- ARX's UpdateMultiple and DeleteMultiple also prove inside the statement that every entity found its row; none
  of the others does.

### Very large loads

5,000,000 values per load, ids returned. From the full run.

| Shape | Raw multi-row VALUES | Raw `unnest` | **ARX** | ARX ÷ unnest | Binary COPY (no ids) |
|---|---|---|---|---|---|
| 10 columns × 500,000 rows | 11.3 s | 4.03 s | **5.33 s** | 1.32× | 1.67 s |
| 50 × 100,000 | 12.1 s | 2.60 s | **3.27 s** | 1.26× | 0.93 s |
| 100 × 50,000 | 12.5 s | 2.37 s | **3.09 s** | 1.30× | 0.87 s |
| 250 × 20,000 | 13.0 s | 2.11 s | **2.81 s** | 1.33× | 0.75 s |
| 500 × 10,000 | 12.3 s | 2.14 s | **2.87 s** | 1.34× | 0.74 s |
| 1,000 × 5,000 | 19.6 s | 3.17 s | **3.34 s** | 1.05× | 0.88 s |
| 1,599 × 3,126 (PostgreSQL's maximum) | 13.2 s | 2.82 s | **3.25 s** | 1.15× | 0.94 s |

- ARX is 2.1–5.9× faster than multi-row VALUES and 1.05–1.34× the best hand-written code (1.25–1.58× on 2026-11-08;
  each shape is 3 single-operation iterations, so a single slow iteration moves it).
- Binary `COPY` is 3.2–3.9× faster than ARX but can't return ids. ARX has no COPY path yet.

### Batch scopes, and mixed row shapes

From the full run, default write buffer (the 50-per-batch raw rows hit the large-command stall, ~5 s, so compare
the 500 and 5,000 columns):

| Approach, 5,000 rows | 500 per batch | 5,000 |
|---|---|---|
| ARX `RunBatchAsync`, savepoints off (the default) | 467 ms | 111 ms |
| ARX `RunBatchAsync`, savepoints on | 427 ms | 129 ms |
| ARX, one call of all 5,000 rows | 127 ms | 107 ms |
| Raw: one transaction, multi-row INSERT per batch | 501 ms | 140 ms |
| EF Core: `BeginTransaction`, `SaveChanges` per batch | 615 ms | 654 ms |

At 500 per batch the scope's commands share one connection and also hit the large-command stall: the same batches
without a scope, each on its own connection, took 159 ms. The 1 MB-buffer run of 2026-11-07, which avoids the stall, put
a scope at 0.4–0.85× the hand-written transaction (`results/2026-11-07-create-multiple-batch-scope-write-buffer-1mb.md`).

Rows that differ in attributes or attribute order can't share one `unnest` statement: 1,000 such rows take 80–111 ms
and 3.7–5.8 MB, against 64 ms and 0.6 MB for rows of one shape.

## Without a catalog

A service created without a catalog resolves each table it writes or reads by id from the database
(`Preload/NoCatalogBenchmarks`, full run).

| Operation | With a catalog | Re-read (default) | Remembered |
|---|---|---|---|
| Create one row | 1.18 ms | 8.81 ms (7.4×) | 1.21 ms (1.02×) |
| CreateMultiple, 100 rows | 3.45 ms | 10.34 ms (3.0×) | 3.61 ms (1.04×) |
| Update one row by id | 1.09 ms | 8.89 ms (8.2×) | 1.09 ms (1.00×) |
| Retrieve one row by id | 0.74 ms | 7.99 ms (10.7×) | 0.80 ms (1.08×) |

- Re-reading is one more round trip per call, about 7–8 ms here (11–12 ms on 2026-11-10). Remembered calls are level
  with a catalog. For anything past simple CRUD in a small app, declare the catalog or preload it.

## Memory

Bytes allocated per operation, full run.

| Operation | **ARX** | Raw best | Raw usual | Dapper | EF Core |
|---|---|---|---|---|---|
| Create one | **12.7 KB** | – | 4.2 KB | 6.1–8.7 KB | 54–62 KB |
| CreateMultiple 1,000 / 10,000 | **0.58 / 5.7 MB** | 0.15 / 1.5 MB | 2.4 / 24.6 MB | one `unnest`: 0.18 / 1.9 MB | 14.8 / 139 MB |
| UpdateMultiple 10,000 | **6.6 MB** | 1.5 MB | 42.1 MB | 37.5 MB | 150 MB |
| DeleteMultiple 100,000 ids | **5.1 KB** | 2.1 KB | 25.4 MB | 3.0 KB | 1.6 MB |
| Retrieve one | **7.0 KB** | 2.8 KB (typed) | 3.3 KB (dictionary) | 3.9 KB | 41.7 KB |
| RetrieveMultiple 100,000 | **59 MB** | 20 MB (typed) | 73 MB (dictionaries) | 42–45 MB | 38 MB |

- Far below EF Core and the usual hand-written SQL for writes; 2.4–4.4× the best hand-written code.
- **CreateMultiple allocates more per row than in the earliest runs** (590 KB for 1,000 rows, was 442–472 KB before the
  injection check came in): cause not yet found.

## Where ARX stands

The speed and memory ratings are measured. The rest are assessments.

| Area | Rating | Notes | Basis |
|---|---|---|---|
| Write speed | **Good** | Single rows level with hand-written SQL. Many rows: 0.81–1.33× the fastest hand-written Npgsql form measured here, faster than the usual hand-written form and far faster than EF Core. Dapper's one `unnest` statement is 0.90× ARX at 10,000 rows (ARX checks and converts each row first) | Measured |
| Execute speed | **Strong** | Level with hand-written calls for procedures, functions, cursors and every way of paging; EF Core can't read a procedure's OUT values | Measured |
| Read speed | **Strong** | Level with hand-written SQL into the same shape for filters, joins, complex 7-table queries and 100,000 rows; 1.15× for one row by id and for 1,000 rows, deep pages 1.13–1.18× in this run. One gap: no "exists" link | Measured |
| Memory | **Good** | Far below EF Core and the usual hand-written writes; reads lighter than a dictionary per row; 2.4–4.4× the best hand-written writes | Measured |
| Security cost | **None measured** | The injection check didn't move time or memory | Measured (A/B) |
| Learning curve | **Low for basics** | A create or a read is a few lines, with no model classes, mappings or SQL text. Attribute names are strings, so a typo is found at runtime, not at compile time | Assessment |
| Safety | **Strong** | Values are always parameters and names are validated in every operation, on every provider; SQL in a name is ARX1017. Stable ARX error codes. Updates and deletes are all or nothing. 1,211 PostgreSQL tests (on Npgsql 8.0.3 and 10.0.3), 333 core tests, and a shared conformance suite on every provider | Tests |
| Provider independence | **Delivered, with documented differences** | PostgreSQL, SQL Server, MySQL, MongoDB and DynamoDB run the same model, and every provider passes the shared conformance suite. What a provider can't do is refused with ARX8001 (see the Capabilities page); some comparisons still behave differently per database (case, nulls, ordering), so test a query on each database you move it to | Code state, conformance suite |
| Production readiness | **Preview** | Every provider is on nuget.org as 0.1.0-preview, with CI on every change. The API can still change before 1.0 | Releases, CI |

## Caveat: large commands on this machine

On this machine, any command larger than Npgsql's 8 KB write buffer, sent on a reused connection, waits about 50 ms
per round trip. That's most likely Docker Desktop's port forwarding; the cause isn't confirmed.

- **Who it affects:** the raw binary COPY rows at 1–1,000 rows (~50 ms), the raw batch-scope rows at 50 per batch
  (~5 s), and hand-written multi-row VALUES at 50–1,000 rows (sooner than ARX's `unnest` statements, so some ratios
  there may favour ARX).
- **Who it doesn't:** the 5M-value loads, and the reads.
- **Workaround:** setting `ARX_BENCH_WRITE_BUFFER=1048576` gives every approach a 1 MB buffer.
