Performance Guide¶
This guide covers the performance of pycubrid running on CUBRID: how to measure it, what is
measured for the current release, and how to profile and tune it.
It does not compare CUBRID with other database engines. A comparison across engines mixes server-engine differences with driver overhead, so it cannot tell you how fast the driver is.
Table of Contents¶
- Performance Overview
- Benchmark Methodology
- Current Release Baseline
- Performance Regression
- Sync and Async Characteristics
- Batch Processing
- Fetch and Memory Performance
- Profiling and Optimization
- Known Limitations
- Reproduction Guide
Performance Overview¶
pycubrid is a pure Python DBAPI2 driver that talks to CUBRID over the CAS binary protocol.
flowchart LR
App[Python Application] --> Driver[pycubrid\nPure Python DBAPI2]
Driver --> CAS[CAS Binary Protocol over TCP]
CAS --> Broker[CUBRID Broker / CAS]
Broker --> Server["(CUBRID Server)"]
flowchart TD
Q[SQL + Parameters] --> Encode[Python object encoding]
Encode --> Packet[CAS packet serialization]
Packet --> Net[TCP round-trip]
Net --> Exec[Server execution]
Exec --> Decode[Row decoding to Python objects]
pycubridis pure Python, so packet encode/decode and row conversion run in the interpreter.- CAS uses a binary protocol with explicit packet framing and parsing; this adds per-request work.
- Small, chatty queries amplify Python-level and round-trip overhead.
- Throughput improves when calls are batched and transaction boundaries are controlled.
Driver performance is tracked against two references, both on the same CUBRID server:
- Release to release — the current
pycubridrelease against an earlier release. - Driver overhead —
pycubridagainst the officialCUBRIDdbdriver.
Benchmark Methodology¶
A benchmark result is only useful when the driver is the one thing that changes.
- Same server. Run every driver or release under test against the same CUBRID server, on the same host, with the same schema and data. Do not compare results across database engines.
- Same transaction mode. Set
autocommitexplicitly on every connection under test. Drivers have different defaults, and a commit per statement changes the result. - Record the environment. CPU, OS, Python version, CUBRID version,
pycubridversion andfetch_size. - Repeat. Run several rounds and report the median and spread, not a single run.
Tools in this repository:
| Tool | What it measures | Needs a server |
|---|---|---|
tests/test_benchmarks.py |
Connect, single-row CRUD by primary key, 100-row insert/select and a 100-execute parameterized loop, every round checked (pytest-benchmark) | Yes |
tests/test_bench_fetch_parsing.py |
FETCH reply parsing of synthetic 2000-row replies | No |
scripts/bench_regression.py |
Median change between two pytest-benchmark JSON files | No |
scripts/profile_*.py |
cProfile of connect, execute and fetch | Yes |
Larger workloads and the harness for comparing drivers live in cubrid-benchmark.
Current Release Baseline¶
Measured on 2026-10-11 for #797 with the
pycubrid-release-baseline
experiment in cubrid-benchmark. That experiment also holds the raw results, the
reproduction command and the full tables.
| Item | Value |
|---|---|
| CPU | Intel Core i5-9400F @ 2.90 GHz, 6 cores (1 thread per core) |
| Memory | 31 GiB |
| OS | Ubuntu 22.04.5 LTS, Linux 5.15.0-198-generic, x86_64 |
| Python | CPython 3.12.13 |
| CUBRID | 11.4.6 (cubrid/cubrid:11.4 in Docker 29.2.1, localhost) |
pycubrid |
main at 2228d66, and 1.10.0 from PyPI |
CUBRIDdb |
cubrid-python v11.3.0.51 (c63ab9c), C extension over CCI |
| Transaction mode | autocommit=False on every connection (connect(autocommit=False) for pycubrid), explicit commit() per write |
fetch_size |
100 (default), plus 1,000 and 10,000 for the fetch cases |
| Data | 10,000 rows of (INT PK, VARCHAR(100), VARCHAR(200), INT) |
| Repetitions | 5 trials per driver in rotating order; samples pooled; warm-up discarded |
| Load | Shared machine with other users active, so results are noisy |
Every reported iteration was checked for the right row counts and values. Times are pooled medians in milliseconds per operation; the full report also has p95, IQR and the per-trial spread.
Current release against 1.10.0¶
No regression was detected. A regression here means the main median is more than 5%
slower and the interquartile ranges do not overlap. The second condition means that only a
slowdown larger than the spread of the samples can be detected. Given the measured spread, the
rule detects slowdowns of at least:
| Operation | Minimum detectable slowdown |
|---|---|
fetchone / fetchmany / fetchall, 10,000 rows |
6-8% |
executemany() 10,000 / 1,000 / 100 rows |
5% / 18% / 172% |
| connect + close | 22% |
| SELECT by primary key, single-row writes + commit | 25-26% |
| single-row execute without commit | 271-307% |
The per-trial medians of main and 1.10.0 overlap in every case except executemany() of
10,000 rows, where main is about 0.7% slower in every trial. That is well below the 5%
threshold.
| Operation | main |
1.10.0 | Ratio |
|---|---|---|---|
| connect + close | 1.50 | 1.49 | 1.01 |
| SELECT by primary key | 0.60 | 0.60 | 1.00 |
| INSERT + commit | 34.5 | 33.9 | 1.02 |
| UPDATE + commit | 33.8 | 33.9 | 1.00 |
| DELETE + commit | 41.2 | 41.3 | 1.00 |
executemany() 100 / 1,000 / 10,000 rows |
125 / 360 / 3,018 | 125 / 358 / 2,998 | 1.00 / 1.00 / 1.01 |
fetchone / fetchmany / fetchall, 10,000 rows |
51.2 / 48.4 / 49.7 | 50.6 / 48.9 / 49.6 | 1.01 / 0.99 / 1.00 |
pycubrid against CUBRIDdb on the same server¶
| Operation | pycubrid |
CUBRIDdb |
pycubrid / CUBRIDdb |
|---|---|---|---|
| connect + close | 1.50 | 1.00 | 1.49× |
| SELECT by primary key | 0.60 | 0.55 | 1.10× |
| INSERT / UPDATE + commit | 34.5 / 33.8 | 33.5 / 33.6 | 1.03× / 1.01× |
executemany() 100 / 1,000 / 10,000 rows |
125 / 360 / 3,018 | 109 / 266 / 2,150 | 1.15× / 1.35× / 1.40× |
fetchone / fetchmany / fetchall, 10,000 rows |
51.2 / 48.4 / 49.7 | 40.6 / 40.7 / 39.4 | 1.26× / 1.19× / 1.26× |
- Single-row writes are bound by the server commit. Latency falls into clusters at about 33, 41, 75 and 83 ms for both drivers, so driver overhead is a small share of a write. (DELETE + commit measured 41.2 ms against 33.8 ms only because the median fell into a different cluster.)
executemany()sends one batch request, but it is still slower thanCUBRIDdb, which runs one prepared execute per row; the time is spent on the server, see where the gap comes from.- Fetching 10,000 rows takes 19-26% longer than with the C extension.
Sync against async¶
PK SELECT throughput, in queries per second. Each worker has its own connection and runs 200 queries.
| Workers | Sync (threads) | Async (pycubrid.aio tasks) |
CUBRIDdb (threads) |
|---|---|---|---|
| 1 | 1,510 | 1,414 | 1,725 |
| 8 | 5,336 | 5,672 | 981 |
| 32 | 5,173 | 5,693 | 956 |
On one connection, async is about 6% slower than sync. With 8-32 concurrent connections it
is 6-10% faster. From 1 to 8 workers, sync scales about 3.5× and async about 4.0×; both level
off after that. Per-query p95 at 32 workers is 13.0 ms for sync and 8.8 ms for async.
CUBRIDdb throughput drops when more than one thread uses it, because its C extension does
not release the GIL during CCI calls.
executemany() batch throughput¶
| Rows | Time incl. commit (ms) | Rows/s | Peak Python heap |
|---|---|---|---|
| 100 | 125 | 803 | 42 KiB |
| 1,000 | 360 | 2,780 | 364 KiB |
| 10,000 | 3,018 | 3,314 | 3.6 MiB |
Client memory grows by about 380 bytes per row, which confirms Known Limitations.
executemany() against CUBRIDdb: where the gap comes from¶
The gap above was investigated in
#822 with the
pycubrid-executemany-overhead
experiment (CUBRID 11.4.6, five trials, medians, commit included). The numbers come from a
shared, loaded machine; only the medians and the phase shares are reliable.
| Batch of | pycubrid / CUBRIDdb INSERT |
UPDATE | DELETE | MERGE |
|---|---|---|---|---|
| 100 rows | 1.13× | 1.13× | 1.13× | 1.49× |
| 1,000 rows | 1.39× | 1.65× | 1.48× | 2.64× |
| 10,000 rows | 1.54× | 1.56× | 1.47× | 4.72× |
- The gap is on the server, not in the driver. For 10,000 INSERT rows, 95% of the statement phase is waiting for the broker's reply. Rendering parameters into SQL is 4% and packet write and parse together are under 1%. The broker SQL log shows the CAS busy for 2,734 of the 2,865 ms the client measured.
- One round trip does not make a batch cheaper. The server spends about 275 µs on each literal statement in the batch, against about 131 µs on each execute of a statement prepared once. A round trip on loopback costs about 76 µs, so sending 10,000 prepared executes still finishes sooner than one batch of 10,000 literal statements.
- Client optimisation cannot close it. Caching the placeholder tokenisation and similar work would recover at most about 2-4% of the statement phase.
- MERGE is the worst case. Literal MERGE statements get few plan-cache hits (548-702 of
1,000 in the broker log), which is why the batch is up to 4.72×
CUBRIDdb. - Memory: about 360 bytes per row while the batch is built; it is released after the call.
What you can do:
- Keep committing once per batch, not once per row. Commit is already 53% of a 100-row batch and identical for both drivers.
- For large MERGE batches, or when a batch is much slower than your own row-by-row
measurement, consider a prepared loop with the explicit
pycubrid.compat.nativecursor (onePREPARE, then one execute per row). It reached 0.99-1.31× ofCUBRIDdbfor INSERT, UPDATE and DELETE and 1.17× for MERGE at 10,000 rows. The measurement ran with autocommit off and one explicit commit per batch; the harness set the driver's autocommit flag directly, and the public way to get the same mode isconnection.set_autocommit(False)followed bycommit(). Measure it on your own workload first:bind_param()takes only scalar INT, string andNULLvalues (collections and LOBs use the dedicatedbind_set()andbind_lob()APIs), and the error behaviour differs. A prepared loop stops at the first failing row. A batch sends every statement to the server even after one fails;executemany()raises the first returned error and, since #841, reports every failed row on it (batch_index,batch_errors,batch_rowcounts; see API_REFERENCE.md). - Do not use
execute()in a loop as a faster alternative. It was 1.6-6.1×CUBRIDdb'sexecutemany(), because every row is a new prepare plus extra requests.
Not established: the gap against CUBRIDdb on CUBRID 10.2, 11.0 and 11.2, and how the server
time splits between the CAS and the engine. Prepared batch paths were measured on all four
versions; see the next section. The placeholder tokenisation is now cached per call; see
below.
Placeholder tokenisation cached per executemany() call¶
Since #837, the literal batch path of
executemany() splits the SQL on its ? placeholders once per call instead of once per
parameter set. An exact tuple or list parameter set also skips the typing.Sequence
check; any other type takes the full check. The rendered SQL is byte-identical:
tests/test_executemany_bind_cache.py compares 390 statements (every literal type family
including subclasses, IANA time zones and the repeated DST hour, quotes, comments,
identifiers, both escape modes) with SQL that executemany() rendered before the change, and
the binding contract in PARAMETER_BINDING.md is unchanged.
10,000 rows of five parameters (INT, VARCHAR, DOUBLE, DATETIME, NULL), Python 3.12,
Intel Core i5-9400F, medians. "Offline" times Cursor.executemany() with
_send_and_receive mocked to answer the batch, as render_with_cursor in that test does;
"live" runs against a local Docker server with autocommit off and the commit outside the
CPU measurement:
| Measurement | Before | After |
|---|---|---|
| Client-side rendering, offline (server reply mocked) | 145 ms | 38.5 ms |
Client CPU inside a live executemany(), CUBRID 11.4 |
194-198 ms | 52-98 ms |
Client CPU inside a live executemany(), CUBRID 10.2 |
195-196 ms | 85-87 ms |
split_on_placeholders time in a live cProfile |
0.12-0.13 s | 0.000 s |
The wall time of the live statement (about 2-6 s on this shared machine) is dominated by the
server and varied by more than the client gain from run to run, so no wall-clock speedup is
claimed. The SELECT per-row loop of executemany() still tokenises once per row, because
each row is a separate execute().
Prepared batch paths: the EXECUTE_ARRAY evaluation¶
#836 measured two prepared alternatives to
the literal batch with the same workload, in the
pycubrid-prepared-batch
experiment. Autocommit was off, the commit was included, and the medians come from five trials on
11.4 and three on the other versions, on a shared machine:
- a prepared loop with
pycubrid.compat.native: onePREPARE, then one execute per row; - a prototype of the
EXECUTE_ARRAYrequest (function code 21): onePREPARE, one request carrying every row's bound values, one close. The prototype lived in the benchmark harness; pycubrid now sends this request withexecutemany(..., prepared=True)(#839, below).
Time of the EXECUTE_ARRAY prototype as a fraction of executemany() at 10,000 rows:
| CUBRID | INSERT | UPDATE | DELETE | MERGE |
|---|---|---|---|---|
| 11.4.6 | 0.40 | 0.33 | 0.35 | 0.15 |
| 11.2.9 | 0.44 | 0.38 | 0.39 | 0.16 |
| 11.0.16 | 0.55 | 0.50 | 0.51 | 0.16 |
| 10.2.18 | 0.53 | 0.51 | 0.51 | 0.61 |
EXECUTE_ARRAYis the fastest path on every version. On 11.4 the statement phase for 10,000 rows falls from 2,887 to 954 ms for INSERT and from 13,020 to 1,739 ms for MERGE. It is also 0.41-0.54× of the prepared loop for INSERT, UPDATE and DELETE.- The prepared loop is not faster everywhere. It is 0.79-1.06× of
executemany()on 11.4 and 11.2, but 1.13-1.22× on 11.0 and 10.2 for INSERT, UPDATE and DELETE. MERGE is faster with it on every version. - Transactions, partial failures and
rowcountmatch the current batch. On all four versions, when one row failsEXECUTE_ARRAYstill executes the other rows, asexecutemany()does today. Only the failing row is undone. With autocommit on, each successful row is committed. With autocommit off,rollback()removes every row andcommit()keeps the successful ones. The per-row counts and their sum are identical, and both paths report the same failed rows on the raised exception (#841). The prepared loop stops at the first failing row instead. - Types and coercion differ. At the time of #836 the prepared encoders took only a 32-bit
int,str,None, INT/STRING collections and LOB handles; #840 widened them to every scalar type the literal path renders. Values are coerced as host variables, not literals, so server error text differs. With autocommit on, a LOB handle fails after the first row's commit.
Decision: executemany() keeps the literal batch by default, because a default switch would change
which values are accepted and how they are converted. The EXECUTE_ARRAY path ships as an opt-in,
executemany(..., prepared=True)
(#839), on the wider prepared type set of
#840. Since
#841 the raised exception reports every failed
row on both paths; see API_REFERENCE.md.
The shipped opt-in: executemany(..., prepared=True)¶
The same comparison through the driver itself, after #839: 10,000 rows of a three-column table
with a primary key, autocommit off, commit included, median of three trials (order alternated),
one container per version on a shared machine (one-minute load average 2.1-2.6). Times are the
literal executemany() and then prepared=True, in milliseconds:
| CUBRID | INSERT | UPDATE | DELETE | MERGE |
|---|---|---|---|---|
| 11.4.6 | 3,044 → 1,159 (0.38) | 3,141 → 1,121 (0.36) | 3,116 → 1,117 (0.36) | 10,047 → 2,012 (0.20) |
| 11.2 | 2,942 → 1,300 (0.44) | 3,615 → 1,196 (0.33) | 5,042 → 1,191 (0.24) | 17,708 → 2,164 (0.12) |
| 11.0 | 1,876 → 1,067 (0.57) | 2,131 → 1,094 (0.51) | 1,817 → 1,125 (0.62) | 8,979 → 1,860 (0.21) |
| 10.2 | 2,054 → 1,102 (0.54) | 2,384 → 1,090 (0.46) | 2,092 → 984 (0.47) | 10,426 → 1,842 (0.18) |
The ratios match the prototype for INSERT, UPDATE and DELETE. MERGE gains more than the prototype
measured on 10.2 (0.18 against 0.61) with this statement shape (MERGE ... USING db_root ON
(t.id = CAST(? AS INT)) on a primary key). The literal 11.2 DELETE time is noisier than the
others. The integration tests (tests/test_integration_execute_array.py) check the transaction,
partial-failure, rowcount, NULL, mixed-type, collection and LOB behaviour on all four versions.
See API_REFERENCE.md for the contract.
Large prepared batches: memory, frame size and failures¶
#854 measured what one large
executemany(..., prepared=True) costs the client. Every row is encoded first and all rows go in
one EXECUTE_ARRAY frame, so cost grows with the batch.
Setup: rows (i, "v%09d" % i, i) into (id INT PRIMARY KEY, v VARCHAR(32), n INT), autocommit
off, one commit. Each case ran once, in a fresh child process. A second fresh process with
tracemalloc gave the heap peak; its time is not used. The measurement ran on 2026-10-12 with
pycubrid 1.12.0 (main at 65bdd93) and Python 3.12.13, on Linux 5.15, an Intel i5-9400F
(6 cores) and 31 GiB of RAM. The servers were Docker containers on the same host:
cubrid/cubrid:10.2 (10.2.18.9024) and cubrid/cubrid:11.4 (11.4.6.1963). The machine was shared,
with a one-minute load average of 2.3-6.2.
"List" passes a list built before the measurement; "generator" passes a one-shot generator. Time
is the executemany() call. Peak RSS is the process maximum (getrusage), and growth is that peak
minus the peak before the call. The heap peak is tracemalloc during the call. The
1,000,000-row cases ran on 11.4 only. The other RSS and heap values come from the 10.2 run; the
11.4 run differed by at most 0.2 MiB.
| Rows | Input | 10.2 time | 11.4 time | Peak RSS | RSS growth | Heap peak | EXECUTE_ARRAY bytes |
|---|---|---|---|---|---|---|---|
| 10,000 | sync, list | 970 ms | 1,132 ms | 30.7 MiB | 5.6 MiB | 4.7 MiB | 460,030 |
| 10,000 | sync, generator | 960 ms | 1,032 ms | 28.9 MiB | 5.6 MiB | 4.7 MiB | 460,030 |
| 10,000 | async, list | 915 ms | 1,063 ms | 31.5 MiB | 5.4 MiB | 4.7 MiB | 460,030 |
| 10,000 | async, generator | 944 ms | 1,083 ms | 29.9 MiB | 5.4 MiB | 4.7 MiB | 460,030 |
| 100,000 | sync, list | 9,318 ms | 10,866 ms | 92.1 MiB | 52.4 MiB | 47.3 MiB | 4,600,030 |
| 100,000 | sync, generator | 8,746 ms | 10,482 ms | 76.0 MiB | 52.8 MiB | 47.3 MiB | 4,600,030 |
| 100,000 | async, list | 9,567 ms | 10,176 ms | 93.6 MiB | 53.0 MiB | 47.3 MiB | 4,600,030 |
| 100,000 | async, generator | 10,805 ms | 12,522 ms | 77.4 MiB | 53.0 MiB | 47.3 MiB | 4,600,030 |
| 1,000,000 | sync, list | — | 123,905 ms | 707.0 MiB | 522.4 MiB | 473.5 MiB | 46,000,030 |
| 1,000,000 | async, generator | — | 122,718 ms | 547.8 MiB | 523.4 MiB | 473.5 MiB | 46,000,030 |
Every case inserted every row: rowcount and COUNT(*) matched the row count. The PREPARE
request was 81 bytes in every case. The commit took 68-164 ms on 10.2 and 131-645 ms on 11.4 up
to 100,000 rows.
- Memory grows linearly with the rows, about 0.5 KB per row here. The heap peak is about 496 bytes per row at 100,000 and at 1,000,000 rows, about 10.8 times the 46 bytes each row takes on the wire. It holds the encoded rows (one binding object per value), the writer's buffer and the final frame. Sync and async are the same.
- A generator lowers the peak only by the size of the input list. It saves building the list (about 16 MiB at 100,000 rows), but the encoded rows are still all kept until the reply.
- The frame size is exact and predictable. The
EXECUTE_ARRAYrequest is 30 bytes plus 46 bytes per row for this schema. The slow test checks that. - Time grows roughly linearly, and most of it is the server. 100,000 rows took 8.7-12.5 s.
1,000,000 rows took about 123 s on 11.4, 11 times the 100,000-row time, while the load
average rose from 3.3 to 6.2. Encoding and serializing alone, timed offline with
_encode_prepared_rows()andExecuteArrayPacket.write(), take about 0.85 s per 100,000 rows. - The frame limit is far beyond these sizes.
DATA_LENGTHis a signed 32-bit integer, so a frame can carry at most 2,147,483,647 bytes. That is about 46.7 million rows of this schema, which would need about 22 GiB of heap first. A batch with large values reaches it sooner: about 2,000 rows of 1 MiB text. Thenexecutemany()raisesDataError(caused bystruct.error) before any byte of the request is sent.PREPAREhas run by then: the session stays open, and the cursor owns the prepared handle and releases it with its next statement.
Transport failures (offline, sync and async, in
tests/test_prepared_batch_failure_invariants.py):
| Failure point | Raised | Can rows have run? | Session afterwards |
|---|---|---|---|
Before PREPARE is sent |
OperationalError, caused by the socket error |
No | Retired |
After PREPARE, before any EXECUTE_ARRAY byte |
OperationalError, caused by the socket error |
No | Retired |
Part of EXECUTE_ARRAY sent |
OperationalError, caused by the socket error |
No (no complete request) | Retired |
| Whole request sent, reply missing or cut | OperationalError "connection lost during receive" |
Yes, and with autocommit they may be committed | Retired |
| Frame over 2,147,483,647 bytes | DataError, caused by struct.error |
No (only PREPARE) |
Kept |
When a failure retires the session, the cursor's handle is dropped, rowcount is -1 and no row
results are kept. The connection stays closed: the next call raises InterfaceError until the
caller reconnects. The batch is never sent again, before or after that reconnect. A generator that
raises while it is read propagates its own exception before anything is sent. After a missing or
cut reply the async cursor reports asyncio.IncompleteReadError as the cause; the sync cursor
detects the end of the stream itself and has no cause.
Decision: no runtime change. No failure, unsafe frame or excessive allocation was reproduced up to
1,000,000 rows, and the behavior is linear and predictable. To bound memory, split the input into
several executemany() calls. Automatic chunking or an input limit would change transaction and
error semantics, so it is not part of this change.
Not measured: CUBRID 11.0 and 11.2; 1,000,000 rows on 10.2 and for the other two inputs; repeated trials (each case ran once); server memory; whether a broker refuses a request below the 32-bit limit (the largest frame sent was 43.9 MiB, which 11.4 accepted; #881 below sent 128 MiB); wide rows, LOB and collection values (measured in #881 below); TLS and remote networks; Python versions other than 3.12; RSS on other operating systems.
Wide values and two connections (#881)¶
#881 repeated the measurement with larger
values and with two batches at once, using scripts/measure_prepared_wide_rows.py. Each row is
(id INT PRIMARY KEY, v <type>):
| Profile | Value | Column |
|---|---|---|
text-1k |
1,000 ASCII characters | VARCHAR(1000) |
text-utf8-8k |
2,700 Hangul characters (8,100 UTF-8 bytes), a new string per row | VARCHAR(8192) |
bytes-1k |
1,024 bytes | BIT VARYING(8192) |
sequence-50 |
pycubrid.types.Sequence of 50 strings of 13 characters |
SEQUENCE(VARCHAR(16)) |
blob |
one create_lob() handle (1 KiB written) bound in every row |
BLOB (sync only) |
The modes are:
- sync and async: one connection.
- sync-2conn: two threads with one connection each.
- async-2conn: two connections on one event loop.
The two connections are separate physical sessions, each with its own table. Both modes synchronize readiness: each connection opens, creates its table and builds its rows, then waits at a barrier (threading.Barrier for the threads, asyncio.Barrier for the tasks, #914). After the barrier, the threads start executemany() within 5 ms of each other. The tasks start one after the other, as the start spread below shows.
Each case inserted 10,000 rows per connection with autocommit off and one commit, in a fresh child process. A second process with tracemalloc gave the heap peak. The heap peak includes the input rows, which are built in the measured process. RSS growth is the process peak minus the peak at start.
The time columns cover only the batches:
- executemany() is each connection's call alone, without the commit that follows. The commit took 0.2-0.5 s.
- The batch window runs from the first executemany() start to the last end.
- The start spread is the gap between the two executemany() starts.
Connecting, creating and dropping the table and building the rows are not in these columns.
The run was on 2026-10-13, with pycubrid main at d9435cc, Python 3.11.15 and CUBRID 11.4.6.1963 in Docker, on the host of the #854 measurement. That host was shared, with a load average of 2.6-8.3. Each case ran once.
| Values | Mode | Rows | executemany() per connection |
Batch window | Start spread | Request bytes per row | Heap peak per row | Heap / request | Heap peak | RSS growth |
|---|---|---|---|---|---|---|---|---|---|---|
text-1k |
sync | 10,000 | 882 ms | 882 ms | 0 ms | 1,023 | 4,566 | 4.5 | 43.5 MiB | 41.3 MiB |
text-1k |
async | 10,000 | 908 ms | 908 ms | 0 ms | 1,023 | 4,545 | 4.4 | 43.3 MiB | 40.8 MiB |
text-1k |
sync-2conn | 20,000 | 1,038 / 1,052 ms | 1,052 ms | 5 ms | 1,023 | 4,041 | 4.0 | 77.1 MiB | 84.7 MiB |
text-1k |
async-2conn | 20,000 | 919 / 999 ms | 999 ms | 70 ms | 1,023 | 3,981 | 3.9 | 75.9 MiB | 74.6 MiB |
text-utf8-8k |
sync | 10,000 | 1,303 ms | 1,303 ms | 0 ms | 8,123 | 30,984 | 3.8 | 295.5 MiB | 286.6 MiB |
text-utf8-8k |
async | 10,000 | 1,477 ms | 1,477 ms | 0 ms | 8,123 | 36,997 | 4.6 | 352.8 MiB | 345.2 MiB |
text-utf8-8k |
sync-2conn | 20,000 | 1,694 / 1,700 ms | 1,705 ms | 5 ms | 8,123 | 26,512 | 3.3 | 505.7 MiB | 574.8 MiB |
text-utf8-8k |
async-2conn | 20,000 | 1,603 / 1,708 ms | 1,827 ms | 223 ms | 8,123 | 28,020 | 3.4 | 534.4 MiB | 497.3 MiB |
bytes-1k |
sync | 10,000 | 1,119 ms | 1,119 ms | 0 ms | 1,046 | 3,589 | 3.4 | 34.2 MiB | 31.5 MiB |
bytes-1k |
async | 10,000 | 1,607 ms | 1,607 ms | 0 ms | 1,046 | 3,568 | 3.4 | 34.0 MiB | 31.1 MiB |
bytes-1k |
sync-2conn | 20,000 | 1,072 / 1,074 ms | 1,079 ms | 5 ms | 1,046 | 3,575 | 3.4 | 68.2 MiB | 55.4 MiB |
bytes-1k |
async-2conn | 20,000 | 1,076 / 1,115 ms | 1,122 ms | 46 ms | 1,046 | 2,991 | 2.9 | 57.0 MiB | 55.2 MiB |
sequence-50 |
sync | 10,000 | 5,005 ms | 5,005 ms | 0 ms | 923 | 8,651 | 9.4 | 82.5 MiB | 81.6 MiB |
sequence-50 |
async | 10,000 | 6,663 ms | 6,663 ms | 0 ms | 923 | 8,631 | 9.4 | 82.3 MiB | 81.4 MiB |
sequence-50 |
sync-2conn | 20,000 | 4,562 / 4,565 ms | 4,570 ms | 5 ms | 923 | 8,177 | 8.9 | 156.0 MiB | 156.7 MiB |
sequence-50 |
async-2conn | 20,000 | 3,775 / 4,445 ms | 4,445 ms | 635 ms | 923 | 8,121 | 8.8 | 154.9 MiB | 156.6 MiB |
blob |
sync | 10,000 | 1,462 ms | 1,462 ms | 0 ms | 86 | 527 | 6.1 | 5.0 MiB | 3.0 MiB |
blob |
sync-2conn | 20,000 | 1,835 / 1,836 ms | 1,841 ms | 5 ms | 86 | 465 | 5.4 | 8.9 MiB | 7.5 MiB |
Every case inserted every row on every connection: rowcount and COUNT(*) matched. The request size per row is exactly the predicted encoding, which the slow test checks.
- Wide values cost about three to four and a half times their wire size (2.9-4.6). The peak holds the input value, its encoded bytes, the writer's buffer and the final frame. For
text-utf8-8kthat is about 31-37 KB per row, or 296-353 MiB for 10,000 rows. - The async cursor adds up to one more copy of the frame (37 KB per row), because the transport keeps a copy of what the socket has not yet accepted.
- Collections cost more per wire byte (about 9 times), because every element is a separate bound string.
- A LOB row carries only its 64-byte handle and is the cheapest.
- Two connections cost the sum of two batches, not more. The peak with two connections is 1.5-2.0 times the one-connection peak, so the per-row heap is not larger. Concurrency adds no amplification, in threads or in tasks. Each connection still holds its whole batch until its reply, so the client peak is the sum of the concurrent batches.
- The two sync threads start within 5 ms of each other.
- The async tasks leave the barrier together, but each encodes its rows without yielding to the loop (#882), so the second batch starts 46-635 ms after the first, about one batch's encoding time.
- The batch window of two connections was 0.7-1.3 times the
executemany()time of one connection. One-connection times varied on this shared host: asyncbytes-1kandsequence-50, for example, were slower alone than as a pair. So these times show no clear cost of concurrency, not a precise ratio. - Large single values and server rejections behave as specified (live, 11.4):
- One 16, 64 and 128 MiB string, each in a one-row prepared batch into a
STRINGcolumn, was stored and read back. A frame of 128 MiB is accepted. - A value too wide for its column (11 characters into
VARCHAR(10)) fails only its row on the server, withProgrammingError-494,batch_index1 andbatch_rowcounts(1, -1, 1). The session stays open, and the other rows are kept aftercommit(). - The client can only know the 32-bit frame limit before sending, and #854 already covers it.
Decision: no runtime change. No unsafe condition was reproduced. Memory is predictable from the request size: about 3-4.6 times the frame for scalar values, more for collections, and summed over concurrent batches. To bound it, split the input into several executemany() calls.
Not measured: - CUBRID 10.2 to 11.2; - more than 10,000 rows per connection for these profiles; - repeated trials; - server memory; - more than two connections; - TLS and remote networks; - other Python versions and operating systems.
Fetch throughput and peak memory by fetch_size¶
10,000 rows over localhost:
| Method | fetch_size 100 |
1,000 | 10,000 | Peak Python heap (fetch_size 100) |
|---|---|---|---|---|
fetchone() loop |
51.2 ms | — | — | 130 KiB |
fetchmany(100) loop |
48.4 ms | 49.0 ms | 49.5 ms | 132 KiB |
fetchall() |
49.7 ms | 48.9 ms | 48.1 ms | 2.1 MiB (the result list) |
About 200,000 rows/s in every case. Over localhost, fetch_size makes no measurable
difference; it may matter on a network with higher latency, which was not measured. Peak memory
was measured only at the default fetch_size of 100.
Not measured¶
- Async for
CUBRIDdb, which has no async API. - A
fetch_sizesweep forCUBRIDdb, which does not expose a fetch size. - Native (C) memory of
CUBRIDdb.tracemalloconly sees the Python heap. - Peak memory at
fetch_size1,000 and 10,000. - Per-operation RSS. Only the process peak was recorded (about 39 MiB).
executemany()againstCUBRIDdbon CUBRID versions other than 11.4, and the split ofexecutemany()server time between the CAS and the engine.- The extra
lastrowidround trip. - Remote networks, TLS, LOB and collection types.
- Python versions other than 3.12, and CUBRID versions other than 11.4 (except the prepared batch evaluation, which ran on 10.2-11.4).
Historical Results (2026-03, pycubrid 0.5.0)¶
The earliest published pycubrid measurements are kept, unchanged, in the
baseline-multilang experiment
of cubrid-benchmark (run 2026-03-16_initial: pycubrid 0.5.0, CPython 3.10.12,
CUBRID 11.2 in Docker).
They are historical and are not a baseline for the current release. Known limitations:
- They predate the cursor memory-bounding fix
(#203,
PR #207, released in 1.6.0). Before that
fix,
CursorandAsyncCursorkept the whole result set in their row buffer, so fetch numbers from that period do not reflect current behaviour. - They predate the fetch fix released in 0.6.0 (commit
bb687dc). In 0.5.0,fetchall()returned only the first fetch batch, so SELECT scenarios that read more than one batch did not read the whole result. - That experiment compares two database engines. This guide does not reproduce it, for the reason given at the top of the page.
Performance Regression¶
The weekly Bug Hunt workflow (.github/workflows/bug-hunt.yml, job perf-trend) runs
tests/test_benchmarks.py against CUBRID 11.4 and compares the medians with the previous run
using scripts/bench_regression.py. It is a trend report: it does not set a fail threshold and
does not gate pull requests or releases.
The release-to-release comparison is published in
Current Release Baseline: main against 1.10.0.
Investigate when:
- The trend report shows a median increase over the previous run.
- A CI run flags a deviation from the previous run's numbers.
- You are about to submit a change to the hot path (protocol.py, packet.py, cursor.py).
Live Microbenchmark Workload¶
tests/test_benchmarks.py measures workload revision micro-explicit-keys-v2 (#922):
- It runs only when the shared test endpoint is configured (
CUBRID_TEST_URLorCUBRID_TEST_HOST) and connects to that endpoint, resolved exactly as for the integration tests. It drops and recreates the tablebench_pycubrid_micro, so use a disposable database.CUBRID_HOST,CUBRID_PORTandCUBRID_DBare not read. - The table has explicit primary keys and no
AUTO_INCREMENTcolumn. Every round, warm-up rounds included, starts from the same state, which an untimed setup seeds and checks. - The timed region holds only the measured statements and, for writes, their commit.
- An untimed check after every round compares every fetched row, every affected-row count and
the whole table. A missing or wrong row fails the benchmark instead of reporting a time. With
--benchmark-disableeach benchmark runs and is checked once. - Each result's
extra_inforecordsworkload_revision, and every benchmark that touches rows also recordsverified_rounds.test_bench_connect_disconnectreads no rows.
| Benchmark | One timed call | Rounds (+ warm-up) |
|---|---|---|
test_bench_connect_disconnect |
connect and close | 20 (+2) |
test_bench_insert_one_row |
1 INSERT into an empty table + commit | 100 (+5) |
test_bench_select_one_row_by_pk |
1 SELECT by primary key + fetchone() |
100 (+5) |
test_bench_update_one_row_by_pk |
1 UPDATE by primary key + commit | 100 (+5) |
test_bench_delete_one_row_by_pk |
1 DELETE by primary key + commit | 100 (+5) |
test_bench_insert_100_rows |
100 single-row INSERTs + 1 commit | 10 (+1) |
test_bench_select_100_rows |
1 SELECT of 100 rows + fetchall() |
50 (+5) |
test_bench_execute_parameterized_loop_100 |
100 SELECTs with execute(sql, params) + fetchone() |
10 (+1) |
test_bench_execute_parameterized_loop_100 sends 100 ordinary PREPARE_AND_EXECUTE requests. It
is not server-side prepared-statement reuse and not executemany(..., prepared=True).
The reference run of this revision on CUBRID 11.4 and 10.2, with its raw pytest-benchmark JSON
and environment, is in
benchmarks/2026-10-14-micro-explicit-keys-v2.
Results from before #922 are historical and are not comparable. Their benchmarks were named
test_bench_single_insert, test_bench_single_select, test_bench_single_update,
test_bench_single_delete, test_bench_bulk_insert_100, test_bench_select_all_100 and
test_bench_prepared_reuse. The old table had an AUTO_INCREMENT key, which DELETE does not
reset, and nothing checked results. test_bench_single_select therefore looked up id = 1
after earlier benchmarks had moved the keys past it, and timed a lookup of a missing row
(reproduced on CUBRID 11.4.6 and 10.2.18). The old INSERT cases also wrote to a growing table,
the old DELETE case timed an INSERT as well, and the old 100-row INSERT timed a DELETE of the
previous round. Because the benchmarks were renamed, scripts/bench_regression.py lists the old
and the new names separately instead of comparing them; only test_bench_connect_disconnect
keeps its name and work.
Comparing Two Runs¶
# Run the live microbenchmarks on the old and the new code:
pytest tests/test_benchmarks.py --benchmark-enable --benchmark-json=before.json
pytest tests/test_benchmarks.py --benchmark-enable --benchmark-json=after.json
# Report the median change per benchmark (add --fail-threshold PCT to fail on a regression):
python scripts/bench_regression.py --baseline before.json --current after.json
Fetch Reply Parsing (Offline)¶
tests/test_bench_fetch_parsing.py times FC8 FETCH reply parsing with no
server (#559). It covers 2000-row synthetic replies for scalar, text, mixed
(with NULLs) and collection workloads, built with the fuzz seed builders, and
checks every parse against their exact expected rows. Without
--benchmark-enable each workload is parsed once as a correctness test, so
required CI has no timing threshold.
# Time it (30 rounds after 3 warmup rounds), with peak allocation in extra_info:
pytest tests/test_bench_fetch_parsing.py --benchmark-enable \
--benchmark-json=fetch-parse.json
# Compare two runs:
python scripts/bench_regression.py --baseline before.json --current after.json
Sync and Async Characteristics¶
pycubrid.connect()returns a blockingConnection;pycubrid.aio.connect()returns anAsyncConnectionthat awaits network I/O instead of blocking.- Both build and parse the same CAS packets, so the per-request encode/decode work is the same.
- An
AsyncConnectionserialises its requests with anasyncio.Lock: one connection runs one request at a time. Async helps when many connections or other I/O run concurrently on one event loop, not when a single connection runs one query after another. - Async timings from the timing hooks include event-loop scheduling latency.
Measured sync and async throughput is in Sync against async.
Event-loop hold of an async batch (#882). AsyncCursor.executemany(..., prepared=True)
encodes every row before its first request, without yielding; the default literal batch of an
INSERT, UPDATE, DELETE or MERGE renders its rows the same way. (Other statements run
one awaited request per row.) scripts/measure_async_prepared_encoding.py
measures that hold with a second task on the same loop that re-schedules itself with
sleep(0). The figures below are for prepared=True with rows (int, 'v%09d', int) on
CUBRID 11.4 in Docker, with Python 3.11.15 on one Linux host, from one run:
| Rows | Input | Encoding alone (ms) | Whole call (ms) | Longest wait of the other task (ms) |
|---|---|---|---|---|
| 1,000 | list | 28.6 | 125.3 | 28.7 |
| 1,000 | generator | 29.2 | 126.7 | 29.2 |
| 10,000 | list | 87.8 | 909.5 | 91.9 |
| 10,000 | generator | 100.9 | 920.6 | 106.6 |
| 100,000 | list | 719.3 | 8,971.0 | 692.7 |
| 100,000 | generator | 759.2 | 8,964.5 | 755.9 |
The longest wait matches the encoding time: the driver-controlled hold is about 7-10 µs per
row of this shape. The rest of the call is not pure network time: it also builds and sends the
EXECUTE_ARRAY frame. But it yields to other tasks while it waits for the PREPARE and
EXECUTE_ARRAY replies, so no longer wait was seen there. A generator adds its own cost to the same hold. The first case includes one-time
warm-up. The hold is not changed: yielding during encoding would need an async encoder separate
from the sync driver's. Split batches of tens of thousands of rows if the loop must stay
responsive. A cancellation requested during encoding is raised before any request is sent (see
the API reference).
Batch Processing¶
executemany() on a sync or async cursor uses a batch path when the statement starts with
INSERT, UPDATE, DELETE or MERGE:
- The driver renders every parameter set into a complete SQL string on the client (see Parameter Binding).
- It sends all the strings in a single
BatchExecutePacket, so the whole batch takes one round trip instead of one per row. rowcountis the sum of the affected rows.
Other statements, such as SELECT, fall back to calling execute() once per parameter set.
With prepared=True, an INSERT, UPDATE, DELETE or MERGE is instead sent as one PREPARE and one
EXECUTE_ARRAY request with typed values, which takes about a third to a half of the literal
batch's time (a fifth or less for MERGE); see
the shipped opt-in.
executemany_batch(sql_list) sends a list of SQL strings you have already rendered in the same
single request.
Practical advice:
- Group a write burst in one explicit transaction instead of committing per statement.
- Split very large parameter sequences into chunks; see Known Limitations for why.
Measured batch throughput is in executemany() batch throughput.
How the batch compares with CUBRIDdb, and why it can be slower, is in
where the gap comes from.
Fetch and Memory Performance¶
fetch_size sets how many rows the driver asks the CAS broker for in each fetch round trip.
It defaults to 100. Set it per connection with pycubrid.connect(..., fetch_size=N) or per
cursor with cursor.fetch_size = N (an integer of at least 1).
fetch_size is not arraysize: arraysize is only the default row count of fetchmany().
Since 1.6.0 (#203), each fetch replaces the cursor's row buffer instead of adding to it, so the buffer holds at most one fetch batch:
fetchone(),fetchmany()and iteration keep memory bounded byfetch_size.fetchall()builds one list with every remaining row, so its memory grows with the result set. It releases the internal buffer when it finishes.- A larger
fetch_sizemeans fewer round trips and more memory per batch; a smaller one means the reverse.
Measured fetch throughput and peak memory for different fetch_size values are in
Fetch throughput and peak memory by fetch_size.
Profiling Fetch¶
scripts/profile_fetch.py profiles fetchone, fetchmany and fetchall against a live
server.
Warning: it writes to the target database (default
demodb): each run creates, fills and drops its own table. Run it only against a disposable database; see Running the Profiling Scripts.
# 1000 rows, 50 fetch iterations (default):
python scripts/profile_fetch.py
# 5000 rows, 20 iterations, fetchmany batch size 100:
python scripts/profile_fetch.py --rows 5000 --iterations 20 --fetch-size 100
# Save .prof for snakeviz:
python scripts/profile_fetch.py --output fetch.prof
Profiling and Optimization¶
Optimization Tips¶
- Use explicit transactions for write bursts instead of per-statement commits.
- Batch inserts and updates with
executemany()where possible. - Reuse long-lived connections to avoid repeated handshake cost.
- Select only required columns and avoid unnecessary full scans.
- Keep hot predicates indexed and validate plans in CUBRID.
flowchart TD
Start[Slow query path] --> Batching{Batchable workload?}
Batching -->|Yes| ExecMany[Use executemany / multi-row patterns]
Batching -->|No| Index{Index coverage good?}
Index -->|No| AddIdx[Add or tune index]
Index -->|Yes| Txn{Too many commits?}
Txn -->|Yes| GroupTxn[Group statements in one transaction]
Txn -->|No| Net[Profile network and CAS round-trips]
Performance Investigation¶
Use this workflow when a benchmark detects a measurable regression. The goal is to reproduce, profile, fix, and verify — without hardcoding thresholds that age badly.
flowchart TD
Detect[Benchmark detects a regression] --> Issue[File a Performance issue\nusing the issue template]
Issue --> Profile[Run profiling scripts\nto isolate the hot path]
Profile --> Optimize["Apply targeted fix\n(see Optimization Tips)"]
Optimize --> Verify[Re-run profiling scripts\nand benchmarks]
Verify --> Close[Attach results to issue\nand close]
-
File an issue — use the Performance Investigation template. Paste the benchmark output and link the CI run that triggered this.
-
Profile the affected operation — pick the script that matches the slow operation:
| Operation | Script |
|---|---|
| Connection handshake | scripts/profile_connect.py |
| INSERT / SELECT / UPDATE / DELETE | scripts/profile_execute.py |
| Row fetching (fetchone/fetchall/fetchmany) | scripts/profile_fetch.py |
-
Optimise — guided by cProfile's cumulative time, focus changes on the top frames. Keep patches targeted; avoid speculative refactors.
-
Verify — re-run the profiling script and the benchmarks. Attach before/after numbers to the issue.
Running the Profiling Scripts¶
All scripts require a live CUBRID instance. Defaults target localhost:33000/demodb with
user dba. For scripts/profile_fetch.py, see Profiling Fetch.
scripts/profile_execute.py and scripts/profile_fetch.py write to the target database:
each run creates its own table _profile_tmp_<12 hex digits> with a plain CREATE TABLE,
so it never reuses or drops an existing table (a name collision fails the run), and drops
only that table afterwards, also when the workload fails. A cleanup failure is reported: it
is added as a note to the workload's exception, or raised as ScratchCleanupError after a
successful workload. Nothing reconnects to retry the cleanup, so a killed run or a lost
connection can leave the table behind; drop it with DROP TABLE _profile_tmp_<hex>.
Warning: run them only against a disposable database.
profile_execute.py --operation selectis not a read-only command either: it still creates and drops its table.
Connection handshake¶
# 100 connect/close cycles (default):
python scripts/profile_connect.py
# Custom target, 50 iterations, save .prof:
python scripts/profile_connect.py \
--host myhost --port 33000 --database testdb \
--user dba --password secret \
--iterations 50 --output connect.prof
Statement execution¶
# All DML operations, 100 iterations each (default):
python scripts/profile_execute.py
# INSERT only, 200 iterations:
python scripts/profile_execute.py --operation insert --iterations 200
# Save .prof for snakeviz:
python scripts/profile_execute.py --output exec.prof
Visualising .prof files with snakeviz¶
snakeviz opens an interactive flame graph in the browser, making it easy to drill into nested call stacks.
Timing & Profiling Hooks¶
For lightweight in-process diagnosis you can opt into the driver's built-in timing instrumentation instead of running the cProfile-based scripts above. Hooks are off by default — when disabled the timing module is never imported and the hot path runs unchanged.
When to use which¶
| Use case | Tool |
|---|---|
"Where is wall-clock time going across connect / execute / fetch / close in my application?" |
enable_timing=True (this section) |
"Which Python frames inside cursor.execute are hot?" |
scripts/profile_execute.py (cProfile) |
| "Did this change make the driver slower than the previous run?" | Performance Regression |
Enabling¶
Pass the enable_timing=True keyword to pycubrid.connect():
import pycubrid
conn = pycubrid.connect(
host="localhost", port=33000, database="testdb", user="dba",
enable_timing=True,
)
Or set the environment variable so timing is enabled for every connection in a process — useful in benchmark harnesses and CI jobs:
The explicit keyword always wins over the environment variable. Async connections support
the same keyword on pycubrid.aio.connect().
Reading the stats¶
cur = conn.cursor()
cur.executemany(
"INSERT INTO bench (n) VALUES (?)",
[(i,) for i in range(1000)],
)
cur.execute("SELECT n FROM bench")
cur.fetchall()
stats = conn.timing_stats
print(stats)
# TimingStats(connect=1 calls, 12.345ms total, 12.345ms avg,
# execute=2 calls, 18.700ms total, 9.350ms avg,
# fetch=1 calls, 4.200ms total, 4.200ms avg,
# close=0 calls)
# Programmatic access (nanoseconds, ints):
exec_avg_ms = stats.execute_total_ns / stats.execute_count / 1_000_000
print(f"average execute: {exec_avg_ms:.3f} ms")
# Reset between phases
stats.reset()
Connection.timing_stats is None when timing is disabled, so guard accordingly:
Categories and granularity¶
| Category | What it covers |
|---|---|
connect |
TCP socket setup + CAS broker handshake + database open. Recorded even on failure. |
execute |
Successful Cursor.execute() and executemany() calls, including their round trips. One executemany() call counts once on every path; the escape-mode probe of a new session is not counted (#899). |
fetch |
Server FETCH requests that fetchone() / fetchmany() / fetchall() send for rows beyond the buffer. Rows that arrived with the execute reply are not counted. |
close |
Connection.close() — CloseDatabasePacket round-trip + socket teardown. |
All cursors created from a connection report into the same TimingStats. Stats are
per-connection, cumulative since the last reset().
Overhead and thread-safety¶
- Disabled — the timing module is not imported; per-call cost is a single attribute
read (
self._timing is None). - Enabled — two
time.perf_counter_ns()calls plus a lock-protected accumulator update per hook (~hundreds of nanoseconds). - The
threading.LockinsideTimingStatslets a monitoring thread safely read counters while a worker thread drives the connection. The connection itself remainsthreadsafety = 1(one connection per thread).
Timing limitations¶
- Async timings include event-loop scheduling latency; treat them as client-side end-to-end latency, not pure server time.
- Counters are cumulative only — there is no per-statement history. If you need per-statement breakdowns, run the cProfile-based scripts in Performance Investigation.
ping()andcommit()/rollback()are not currently timed.
Known Limitations¶
- Pure Python. Packet encode/decode and row conversion run in the interpreter; there is no C extension.
executemany()holds the whole batch in memory. The batch path renders every parameter set into a SQL string before sending, then serialises all of them into one request. Client memory grows with the number and size of the parameter sets; split very large sequences into chunks.fetchall()materialises the result. Usefetchone(),fetchmany()or iteration for large result sets.- Async timing includes the event loop. See Sync and Async Characteristics.
- The published 2026-03 numbers are historical. Use the Current Release Baseline instead.
Reproduction Guide¶
- Start a CUBRID server. The repository's
docker compose up -dstarts one onlocalhost:33000with databasetestdb(see Development). - Run the microbenchmarks on each version you compare, on the same host and server, and save the JSON output.
- Compare the runs with
scripts/bench_regression.py. - For larger workloads, or to compare
pycubridwithCUBRIDdbon the same server, use the cubrid-benchmark harness. Setautocommitexplicitly for every driver. - Report the result in a Performance issue with the environment listed under Benchmark Methodology.