Skip to content

Port from psycopg2 to psycopg3 - #88

Merged
roed314 merged 3 commits into
roed314:masterfrom
roed-math:psycopg3-port
Jul 21, 2026
Merged

Port from psycopg2 to psycopg3#88
roed314 merged 3 commits into
roed314:masterfrom
roed-math:psycopg3-port

Conversation

@roed-math

Copy link
Copy Markdown

Implements the driver switch evaluated for #27, on top of the extras removal (#59). psycopg2 is in maintenance mode; psycopg3 is where development happens, gives per-connection adaptation (no more process-global register_adapter), automatic server-side prepared statements, and an async option on the same codebase if ever wanted.

What changes

  • psycopg2.sqlpsycopg.sql everywhere (API-compatible), and psycodict now re-exports SQL, Identifier, Placeholder, Literal, Composable, Composed so downstream projects can compose queries without importing a driver directly.
  • execute_valuesexecutemany with the VALUES template expanded inside _execute (pipelined in psycopg3); no caller changes.
  • cursor.copy_from/copy_to/copy_expert → the cursor.copy() streaming API, via a new _copy_from_stdin helper.
  • The psycopg2 adaptation protocol (register_adapter/ISQLQuote/adapt, the Json subclass, register_json, new_type) → psycopg3 Dumper/Loader classes registered per connection. Custom types are dumped as text with unknown oid, so the server infers types from context exactly as it did for psycopg2's client-side literals — this is why behavior is unchanged.
  • mogrify only exists on ClientCursor: a PostgresBase._mogrify helper creates one on demand for logging/error messages.
  • Server-side binding corner cases (all found by the test suite):
    • DDL cannot take bound parameters: sequence creation in _copy_from and index storage parameters now inline literals.
    • Typed parameter arrays: psycopg3 picks smallint[] for a list of small ints, so $in/$nin cast ANY(%s) to the column's type (also a large plan-quality win, per the evaluation), and _create_typecast now covers $overlaps and bare array equality (integer[] = smallint[] has no operator).
    • ServerCursor.itersize set to 2000 to match psycopg2's named cursors (default 100 multiplies round trips).
  • join_search's qualify() used psycopg2-only Composed.seq / Identifier.string.
  • Extras pgsource/pgbinary now install psycopg / psycopg[binary]names unchanged, so lmfdb-lite and the CI need no edits.
  • Tests: driver-attribute introspection rewritten via iteration and as_string(); the Array wrapper now renders postgres array literals ({1,2,3}) rather than ARRAY[...] expressions — under server-side binding a parameter is a value, not SQL; the packaging job's import-guard checks the new psycopg[binary] hint.

Verification

  • 519 passed / 24 skipped, zero failures under psycopg 3.3.4 on PostgreSQL 18; ruff clean.
  • A 35-step workload harness (DDL, inserts, the full query language, jsonb paths, arrays, buffered cursors, update/upsert/delete, stats, copy/reload) run against the same base commit under psycopg2: output byte-identical.
  • All 21 devmirror tests pass against the live mirror under psycopg3.
  • Benchmarks (from the Upgrade backend library #27 evaluation): parity overall — point queries and searches within a few percent, updates ~24% faster (prepared statements), residual 1.2–1.4× only on bulk export/full-scan paths.

Merge ordering

The downstream.yml jobs will stay red on this PR until the paired downstream PRs merge: lmfdb, seminars, and psetpartners each import psycopg2.sql directly today. Those PRs are driver-tolerant (they follow whichever driver the installed psycodict uses), so the safe order is: merge the downstream PRs first (they work fine against current master), at which point this PR's downstream jobs go green, then merge this. Links will be added below as they open.

Caveat for the release notes: the Sage-mode dumpers (Sage Integer / RealLiteral) are written but exercised only via the Sage CI job — worth a look during review.

🤖 Generated with Claude Code

psycopg2 is in maintenance mode; psycopg3 is where development happens,
has per-connection adaptation, automatic prepared statements, and an
async story if we ever want one.  The evaluation (35-step parity harness,
benchmarks against a scratch server and devmirror) found behavior
byte-identical and performance at parity after the tuning below.

Library:
- psycopg2.sql -> psycopg.sql everywhere (API-compatible)
- execute_values -> executemany with the VALUES template expanded in
  _execute (pipelined in psycopg3); no caller changes
- cursor.copy_from/copy_to/copy_expert -> cursor.copy() streaming, via a
  new _copy_from_stdin helper (base, table, statstable call sites)
- the psycopg2 adaptation protocol (register_adapter/ISQLQuote/adapt,
  Json subclass, register_json, new_type) -> psycopg3 Dumper/Loader
  classes registered per connection in setup_connection; custom types
  are dumped as text with unknown oid so the server infers types from
  context, matching psycopg2's client-side interpolation semantics
- mogrify only exists on ClientCursor: PostgresBase._mogrify creates one
  on demand for logging and error messages
- DDL cannot take bound parameters under server-side binding: sequence
  creation in _copy_from and index storage parameters now inline
  literals
- typed parameter arrays can change plans or lack operators: $in/$nin
  cast ANY(%s) to the column's type, and _create_typecast covers
  $overlaps and bare array equality (psycopg3 picks smallint[] for a
  list of small ints; integer[] = smallint[] has no operator)
- ServerCursor.itersize set to 2000 to match psycopg2's named cursors
- join_search's qualify() used the psycopg2-only Composed.seq and
  Identifier.string attributes
- extras pgsource/pgbinary now install psycopg / psycopg[binary]

Tests and CI:
- test imports moved to psycopg.sql / psycopg errors
- Array wrapper now renders postgres array literals rather than
  ARRAY[...] expressions (server-side parameters are values, not SQL);
  the adapter-protocol test is replaced by literal-rendering cases
- Json wrapper contract test replaces the psycopg2-subclass test
- composable introspection in test_utils uses iteration and as_string()
  (Composed.seq / SQL.string / Identifier.strings do not exist in
  psycopg3)
- the packaging job's import guard now checks the psycopg[binary] hint

Verified: 519 passed / 24 skipped under psycopg 3.3.4 on PostgreSQL 18;
byte-identical 35-step workload parity against the base branch running
psycopg2; all 21 devmirror tests pass against the live mirror.  The
downstream.yml jobs stay red until lmfdb and seminars port their own
psycopg2.sql imports (paired PRs, as with the extras transition).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@roed-math

Copy link
Copy Markdown
Author

The driver-tolerant downstream PRs are open — each works with psycodict on either side of this transition (they probe psycodict.SQL.__module__ rather than importing a driver directly), so they can merge now, in any order, before this PR:

  • LMFDB/lmfdb#7071 — compat module for the 16 psycopg2 imports (incl. the QueryCanceledError search-timeout semantics, verified under both drivers); requirements switch to psycodict[pgbinary].
  • roed314/seminars#977 — compat module + a driver-tolerant replacement for the raw cursor.copy_to in sanitize.py (which turned out to have been broken since psycopg2 2.9 anyway).
  • AndrewVSutherland/psetpartners#578 — submodule bump past the extras removal + from psycodict import SQL (its only driver import).

Once lmfdb's and seminars' PRs merge to their mains, the downstream.yml jobs here should go green and this PR becomes mergeable on green CI.

- analyze() still called cursor.mogrify, which only ClientCursor has
  under psycopg3, so every call raised AttributeError before running
  EXPLAIN; it now uses the _mogrify helper.  (Only the printed query
  needed interpolating -- EXPLAIN itself accepts bound parameters under
  server-side binding, which the new tests confirm.)
- copy_to() rejected the null= option that psycopg2's copy_to (and
  psycodict's passthrough of it) supported; the COPY statement now
  carries NULL alongside DELIMITER, mirroring copy_from and reload.

Regression tests for both: analyze prints the interpolated query and a
plan (with and without execution), and a copy_to/copy_from round trip
with null='NULL' -- including a value that merely starts with the
marker.  analyze also re-verified against devmirror, where the report
reproduced the failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AndrewVSutherland pushed a commit to AndrewVSutherland/psetpartners that referenced this pull request Jul 20, 2026
Two changes preparing for psycodict's 1.0 release:

- The psycodict submodule moves from 35f09b4 (2021-era) to current
  master (9e65584), which includes the removal of the search/extras
  table split, the new test suite, and a long run of bug fixes.
  psetpartners is unaffected by the extras removal: it never used
  extra_columns/extrafile, and its projection=3 calls survive (3 is
  kept as an alias for all-columns-plus-id).  All psycodict API it
  uses -- PostgresDatabase(config), DelayCommit, IdentifierWrapper,
  Configuration, search/lucky/lookup/update -- is unchanged.

- dbwrapper.py imports SQL from psycodict rather than psycopg2.sql.
  psycodict is switching from psycopg2 to psycopg3
  (roed314/psycodict#88), and composed fragments must come from the
  driver psycodict is built on; psycodict re-exports SQL from the
  right place on both sides of that transition, so this one-line
  change makes the code driver-agnostic.  When the submodule is later
  bumped past the port, the psycopg package must be installed in the
  deployment environment in place of psycopg2 (neither is listed in
  requirements.txt; the driver is installed out of band).

Verified by importing through the submodule path with the pinned
commit (SQL resolves to psycopg2.sql, composition works) and with the
psycopg3 branch checked out in the submodule (SQL resolves to
psycopg.sql).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sage.all.RealNumber is the create_RealNumber factory, not a class, so
registering the dumper on it raised TypeError under Sage; import the
class from sage.rings.real_mpfr instead (as encoding.py already does).
The lmfdb-env action stripped LMFDB's psycodict requirement line --
which, now that it reads psycodict[pgbinary], was the only source of
the psycopg driver -- and then installed psycodict with --no-deps;
install with the pgbinary extra instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
roed314 added a commit to LMFDB/lmfdb that referenced this pull request Jul 21, 2026
psycodict is switching from psycopg2 to psycopg3
(roed314/psycodict#88).  Query fragments composed in the LMFDB are
executed by psycodict's _execute, so they must come from whichever
driver psycodict is built on -- and since both drivers can be installed
at once, try/except imports are not a valid probe.

The new lmfdb/utils/psycopg_compat.py keys off psycodict's re-exported
SQL and provides SQL, Identifier, Placeholder, Literal, Composable,
Composed plus DatabaseError, DataError, NumericValueOutOfRange and
QueryCanceledError from the matching driver.  All sixteen direct
psycopg2 imports (search/verify/knowledge/users/api/groups/ecnf, the
dynamic-knowls test and the old belyi script) now import from it, so
the LMFDB works unchanged on either side of psycodict's transition;
once psycodict is pinned past it, the module body can shrink to plain
psycopg imports.

requirements.txt now asks for psycodict[pgbinary], which installs
whichever driver the checked-out psycodict needs (psycopg2-binary
today, psycopg[binary] after the port) instead of naming psycopg2
directly.

Verified by loading the compat module against both psycodict versions:
all names resolve from the matching driver, composed statements execute
through _execute, and a statement_timeout raises the exported
QueryCanceledError under both -- that exception is what turns timeouts
into the 'search took too long' page rather than a 500.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
roed314 added a commit to roed314/seminars that referenced this pull request Jul 21, 2026
psycodict is switching from psycopg2 to psycopg3
(roed314/psycodict#88).  Composed query fragments are executed by
psycodict's _execute, so they must come from whichever driver psycodict
is built on; both drivers can be installed at once, so the new
seminars/psycopg_compat.py keys off psycodict's re-exported SQL and
provides SQL, Composed, Identifier, Literal, Placeholder and
DatabaseError from the matching driver.  All direct psycopg2 imports
(utils, seminar, talk, create/main, lock, importing/sanitize) now go
through it, so the site works on either side of the transition; once
psycodict is pinned past it, the module body shrinks to plain psycopg
imports.

sanitize.py's raw cursor.copy_to (an API removed in psycopg3) becomes
copy_table_to_file in the compat module, with a branch per driver.
Replacing it also fixes a latent bug: the manual column quoting it used
has been broken since psycopg2 2.9, which quotes column names itself --
the export raised UndefinedColumn ('column ""id"" does not exist')
on any modern psycopg2.

requirements.txt now asks for psycodict[pgbinary], which installs
whichever driver the checked-out psycodict needs, instead of naming
psycopg2-binary directly.

Verified by running the compat module against both psycodict versions
over a live server: names resolve from the matching driver, composed
statements execute through _execute, and copy_table_to_file produces
byte-identical exports (including delimiter escaping) under both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@roed314
roed314 merged commit e1388c6 into roed314:master Jul 21, 2026
18 of 20 checks passed
@roed314 roed314 mentioned this pull request Jul 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants