The phase log
The driver was built across 39+ phases, each with a focused scope and a decision log. This page is a high-level index; the gory details (with rationale, alternatives considered, and rollback notes) live in docs/DECISION_LOG.md.
Foundation (Phases 1–10)
Section titled “Foundation (Phases 1–10)”| Phase | Title | Outcome |
|---|---|---|
| 1 | Socket + minimal SQ_INFO | First handshake against the dev container |
| 2–4 | Login, DBOPEN, error decoding | Can connect and select a database |
| 5 | Statement execution | SELECT 1 works |
| 6 | Parameter binding | ?-placeholders, basic types |
| 7 | Logged-DB transactions | Discovered Informix needs explicit SQ_BEGIN per tx in non-ANSI mode |
| 8 | BYTE / TEXT (legacy in-row blobs) | Needs blobspace |
| 9 | Scrollable cursors | SQ_SCROLL PDU, position semantics |
| 10/11 | Smart-LOB read & write | Architectural pivot to SQ_FILE intercept; ~3× smaller than projected |
Hardening (Phases 12–20)
Section titled “Hardening (Phases 12–20)”| Phase | Title | Outcome |
|---|---|---|
| 12 | Type system overhaul | Per-column readers (predecessor to Phase 37) |
| 13 | DECIMAL / MONEY exact precision | decimal.Decimal round-trip |
| 14 | DATETIME range typing | Returns date / datetime / time per field range |
| 15 | INTERVAL types | Custom IntervalYM, timedelta for D-to-F |
| 16 | Async API | Pivot to thread-pool wrapping (~250 lines) instead of full async refactor (~2000 lines) |
| 17 | Connection pool (sync) | min/max sizing, acquire timeout, max idle |
| 18 | Connection pool (async) | Mirror of sync API on aio.Pool |
| 19 | TLS support | Bring-your-own-context, tls=True for dev |
| 20 | Locale / Unicode | client_locale, full mapping in Connection.encoding |
Production review (Phases 21–30)
Section titled “Production review (Phases 21–30)”| Phase | Title | Outcome |
|---|---|---|
| 21 | Type-checking pass | py.typed, full mypy/pyright coverage |
| 22 | Error code mapping | SQLCODE → exception per reference |
| 23 | Health checks | Pool validates idle connections before return |
| 24 | Statement caching | Per-connection prepared-statement cache |
| 25 | Fast-path call (SQ_FPROUTINE) | Direct UDF/SPL invocation, bypassing PREPARE |
| 26 | CRITICAL | Pool returned connections with open transactions (fixed) |
| 27 | CRITICAL | Per-connection wire lock + async cancellation safety |
| 28 | HIGH | _raise_sq_err bare-except masking wire desync (fixed) |
| 29 | Cursor finalizers | Server-side resource leak on mid-fetch raise (fixed) |
| 30 | Hardening pass | 5 medium-severity audit findings, all closed |
After Phase 30: 0 critical, 0 high, 0 medium audit findings remain. Driver is production-ready.
Performance (Phases 31–39)
Section titled “Performance (Phases 31–39)”| Phase | Title | Result |
|---|---|---|
| 31 | Statement cache LRU tuning | Better hit rate on repeated queries |
| 32 | Cursor lifecycle optimization | Fewer round-trips on small queries |
| 33 | Pipelined executemany | 1.6× faster than IfxPy on bulk inserts |
| 34 | LRU caches for type lookup | Removed dispatch overhead on hot paths |
| 35 | Memory profile pass | Identified 100k-row baseline |
| 36 | IfxPy comparison harness | Established the 2.4× bulk-fetch gap |
| 37 | Per-column reader strategy | −10% on bulk SELECT, ratio → 2.10× |
| 38 | exec()-based row-decoder codegen | Further −12%, ratio → 2.04× |
| 39 | Connection-scoped buffered reader | −32% on bulk SELECT, ratio → 1.05–1.15× |
The Phase 37–39 trajectory is documented in detail at The buffered reader →, including the architectural mistake the first pass got wrong.
Field reports and systematic review (2026.08–2026.09)
Section titled “Field reports and systematic review (2026.08–2026.09)”Phase 30’s audit found nothing left. Then real schemas arrived, and the audit’s clean bill of health turned out to be a statement about the questions it had asked rather than about the driver.
A field report from a user running Informix 12 surfaced three type-framing bugs in a single afternoon: INT8 / SERIAL8 never decoded at all, NCHAR losing its first character, and BOOLEAN corrupting every column after it. Fuzzing the type matrix found more, and each one had the same shape as the last.
2026.09.02 went back over the driver looking for that shape rather than for new symptoms, and found eleven bugs.
| Area | What was wrong |
|---|---|
| Row framing | Nothing ever checked that a row consumed its own payload. Fourteen framing bugs had reached users, every one detectable for free |
| Smart LOBs | Read as a flat 72-byte field when they occupy 149, shifting every column after a non-final BLOB |
| Composites | A NULL collection skipped its length field, the same bug as NULL LVARCHAR twelve lines away |
| Transactions | rollback() after a SQL BEGIN WORK sent nothing and reported success |
| Classification | Comments, CTEs and parenthesized selects were run as DML and failed with -260 |
| Scrollable cursors | A second statement on the connection got -285 and destroyed the cursor too |
| Statement release | Two of eight exits leaked a failed statement, bricking the connection |
| Cursor finalizers | Cleanup could land inside another statement, and the lock probe was blind to its own thread |
| Async | The whole layer shared the process-wide thread pool, so cancellations starved it |
| Socket | Two readers on one stream agreed only by accident of how the server replies |
The pattern behind almost all of them: a hazard that was understood and guarded at the site where it was first observed, rather than at the abstraction that owned it. The guard then never travelled to its siblings. Four separate hand-written copies of the same UDT envelope, three of them wrong. Six hand-rolled copies of the same statement-release cleanup, two missing entirely.
Two of the fixes came from asking the server instead of guessing. statement_type and statement_id had both been parsed out of every DESCRIBE response into a metadata dict that nothing read, while the code that needed them inferred the answer from the first word of the SQL and got it wrong five ways.
The cheapest fix was also the most valuable. Asserting that a row decoder lands exactly on the end of its payload costs one integer comparison, and it would have caught all fourteen framing bugs at the byte where each happened rather than three releases later in somebody’s result set.
Test count went from 241 to 457 across three server versions over this stretch.
2026.09.03
Section titled “2026.09.03”Two more, one of each kind.
The :N placeholder rewriter was a bare regular expression, so it
rewrote the inside of string literals: 'http://host:8080/x' was stored
as 'http://host?/x'. Same family as the statement-classification bug
above, and the last place the driver inferred meaning from SQL text
instead of handling it. Replaced with a scanner using Informix’s own
lexical rules, measured rather than assumed. One of those rules would
have been got wrong from habit: a backslash escapes nothing, so 'a\'b'
is an unterminated string and draws -282. A scanner written to
Postgres reflexes desyncs there and corrupts everything after it.
row_factory=Row adds column-name and attribute access, on request from
a user coming from pyodbc. Opt-in, because it costs about 9% on bulk
fetch and that is not a trade to make for someone who never reads a
column by name.
Testing the second one found the pattern again, this time in code
written an hour earlier. The names a column could shadow were
hand-listed as count and index, the two tuple methods; then keys,
_asdict and _fields were added to the same class and the list was
never revisited. A column called keys returned a bound method, in
silence. The fix was to compute the set from the class rather than
lengthen the list, and to parametrize the test over that computed set so
it grows with the class.
The interesting part is where testing found it. A differential test across twenty columns covering every awkward type, populated and fully NULL, found nothing. The bug was in six lines of name bookkeeping that looked obviously correct, and had been correct when written. Anything that must stay in sync with a class by hand eventually will not.
Notable architectural pivots
Section titled “Notable architectural pivots”The decision log calls out four moments where the obvious choice would have been wrong:
- Phase 10/11: abandoning
SQ_FPROUTINE+SQ_LODATAforSQ_FILEintercept. Smaller, simpler, same correctness. - Phase 16: thread-pool async instead of a full async refactor. ~88% less code, same FastAPI surface.
- Phase 27: adding a per-connection wire lock instead of relying on PEP 249’s “don’t share connections” advice. Made accidental sharing safe rather than catastrophic.
- Phase 39: buffer on the connection, not on the reader. Got it wrong on the first pass; the bug surfaced as a hang on pipelined
executemany. Fixed in ten minutes once the architectural mistake was named.
What’s next
Section titled “What’s next”The roadmap (loose, not committed):
- Phase 40+ (codec): Numpy-backed bulk decode for homogeneous columns. ~5× speedup target on analytical workloads.
- Phase 4x (protocol): Optional Cython acceleration for the codec hot loop. Would compromise “pure Python”, so it would sit behind a build flag.
- Phase 5x (API): Native
callprocwith named parameters, IBM-specific scrollable cursor extensions for full IfxPy parity.
The phase log is updated as work lands. The repo’s CHANGELOG.md is the source of truth for shipped changes.