# The same PostgreSQL error in twenty clients

Forty PostgreSQL errors (twenty on 30 October 2026; eight more and three connection errors on 14 November 2026; one more statement error and four connection errors on 15 November 2026, `invalid-json` with a bound-parameter variant later that night, `out-of-shared-memory` after that, and two errors on a hot standby, `conflict-with-recovery` and `read-only-transaction`, last) raised from twenty client versions, with the literal text, the exception class and the field that holds the SQLSTATE. Nothing in `resultaat.json` comes from documentation: every value is output of a run. Results are from PostgreSQL 17.11; a run on 18.6 gave identical classes, text and codes, apart from the host name in the connection errors and the raw bytes lib/pq 1.12.3 returned for stack-depth (see `verschil_pg18`).

## Running it

Needs Docker and python3. Every throwaway role has the password `test`.

    ./run.sh              # everything on PostgreSQL 17 and 18, writes resultaat.json
    ./run_lang.sh java 17 # one language: python|node|go|ruby|php|java, server 17|18
    python3 merge.py      # merge again from out/
    ./run.sh --opruimen   # removes only the containers, network and cache of this run

`setup.sql` creates the schema. `scenarios.json` holds, per error, the steps per session, the same for every client. `resultaat.json` has `fouten` (all errors; each later run repeated the earlier errors with the same classes, first lines and codes), `extra_parameters` (with a bound parameter), `orm` (through models instead of raw SQL) and `verschil_pg18`. Keys and comments are in Dutch; the measured values are the original output.

## How it runs

Session `a` is the client under test with the limited role `app`; session `b` is a second connection as `postgres`. Steps run one after the other, so that PHP does exactly what the others do. A deadlock is really concurrent: `b` starts a third session through `dblink_send_query`, which blocks on a row of `a`, and that third session has `deadlock_timeout = 30s` so that `a` always receives the error.

## Limits

- `invalid-json-param` (in `extra_parameters`) binds the string `{'a': 1}` as `$1` for a `jsonb` column: fourteen clients got `22P02` like the literal, while pgJDBC, Spring, Hibernate (`character varying`) and Prisma 6 (`text`) got `42804`, `column "payload" is of type jsonb but expression is of type …`, naming the type the server saw for the parameter.
- The `connect` step field also takes `port` (`connection-refused`: port 5499, where nothing listens), `database` (`no-pg-hba-entry`: `run.sh` changes the catch-all line to `host /^(?!nohba$) all all scram-sha-256`, a regular expression on the database name that needs PostgreSQL 16 or later, so no line matches a TCP connection to `nohba`) and `host_suffix` (`too-many-connections`: a separate server per version, `-max`, with `max_connections=5`, `superuser_reserved_connections=3` and two idle `app` sessions).
- `out-of-shared-memory`: again a separate server per version, `-locks`, with `max_locks_per_transaction=10` and `max_connections=20`, so that the shared lock table holds only about a thousand entries (between 700 and 900 on 17, between 1200 and 1500 on 18). `lk.events` is partitioned by list into 2,000 empty partitions, each created in its own transaction; `SELECT count(*) FROM lk.events` locks every partition in one transaction and gets 53200 with `HINT: You might need to increase "max_locks_per_transaction".` This is the ordinary lock table, not the predicate-lock variant (`max_pred_locks_per_transaction`).
- `conflict-with-recovery` and `read-only-transaction`: a primary and a streaming standby per version, `-rep` and `-rep-sb`, the standby made with `pg_basebackup -R` and running with `hot_standby=on`, `max_standby_streaming_delay=0` and `hot_standby_feedback=off`. On the primary a loop updates all 10,000 rows of `items` and runs `VACUUM items` every half second (`vacuum_truncate=false`, so the table never shrinks and only a snapshot conflict can arise). On the standby, `SELECT CAST(count(*) AS text), CAST(pg_sleep(5) AS text) FROM items` holds a snapshot, and the next VACUUM cancels it with 40001 and `DETAIL: User query might have needed to see row versions that must be removed.`, usually within a second (verified 13 of 13 times with psql on 17 and on 18 before the run). `INSERT INTO items` on the same standby gets 25006, `cannot execute INSERT in a read-only transaction`. Both run in session `c`, with `host_suffix` `-rep-sb`.
- `server-closed`: session `b` ends `a` with `pg_terminate_backend(pid, 5000)` and `a` runs its query again. Prisma 7 raised no error: the `bewaar` field records the backend pid before and after, and they differ, so its pool reconnected. node-postgres delivered the server's error only as an `'error'` event on the idle client; the runner attaches a listener (without one, the event ends the Node process) and stores the events in `error_event_voor_query`.
- Connection errors (`password-auth-failed`, `role-does-not-exist`, `role-does-not-exist-scram`) run in a third session `c` with its own user and password. Under `scram-sha-256` an unknown role gives `password authentication failed` (28P01), not `role … does not exist`; `run.sh` puts a `trust` line for the role `ghost` at the top of `pg_hba.conf` to get 28000.
- `database-in-use`: our Hibernate cells run in a transaction, where `DROP DATABASE` is refused with 25001; Hibernate never reaches 55006.
- `stack-depth`: lib/pq 1.12.3 returned `pq: server error:` followed by raw bytes and no code; 1.10.9 returned 54001. The CONTEXT of this error runs to tens of kilobytes; in the published `resultaat.json` strings over 4,000 characters are cut, with a note; `./run.sh` writes them in full.
- Hibernate cells are native queries in a transaction; four errors were also measured through an entity.
- The Spring class under Hibernate comes from calling `HibernateJpaDialect.translateExceptionIfPossible` directly; no Spring Boot application was running.
- Prisma's first line is always `Invalid ... invocation:`; the useful line is in `melding_regel`.
- Prisma `25P02` was measured with `$transaction(async tx => ...)`.
- Django cells are a raw cursor; the ORM path is under `orm.django-orm`.
- `permission-denied-schema-public` was run on 17 and 18 only.

## Versions

| Client | Version |
|---|---|
| psycopg2 | 2.9.13 |
| psycopg3 | 3.3.6 |
| asyncpg | 0.32.0 |
| sqlalchemy | 2.1.6 (psycopg2-binary 2.9.13) |
| django | 6.1.2 (psycopg 3.3.6) |
| django-psycopg2 | 6.1.2 (psycopg2-binary 2.9.13) |
| node-pg | 8.23.1 |
| prisma | 7.10.1 |
| prisma6 | 6.19.3 |
| pgjdbc | 42.7.14 |
| spring-jdbc | 7.0.10 (pgjdbc 42.7.14) |
| spring-jdbc6 | 6.2.19 (pgjdbc 42.7.14) |
| hibernate | 7.4.14.Final (pgjdbc 42.7.14) |
| hibernate6 | 6.6.59.Final (pgjdbc 42.7.14) |
| pgx | v5.11.0 |
| lib-pq | v1.12.3 |
| lib-pq-1.10 | v1.10.9 |
| ruby-pg | 1.7.0 |
| activerecord | 8.1.4 (pg 1.7.0) |
| php-pdo | 8.4.27 (pdo_pgsql 8.4.27) |
