From e951a06800fae075d0f180ed0b409b2ed281e40e Mon Sep 17 00:00:00 2001
From: Jelte Fennema-Nio <postgres@jeltef.nl>
Date: Tue, 16 Dec 2025 09:25:48 +0100
Subject: [PATCH v4 4/7] Add pytest infrastructure to interact with PostgreSQL
 servers

This adds functionality to the pytest infrastructure that allows tests
to do common things with PostgreSQL servers like:
- starting
- stopping
- connecting
- running queries

Especially the running of queries is designed in such a way that it.
Types get converted to their Python counter parts automatically. Errors
become actual python exceptions. And there's logic to automatically
unpack single fields or single rows so you don't have to do rows[0][0]
if the query only returns a single cell. In a similar vein Postgres
errors automatically get converted to dedicated Python exception types.
---
 pytest.ini                                 |    3 +
 src/backend/utils/errcodes.txt             |    5 +
 src/test/pytest/libpq/__init__.py          |   36 +
 src/test/pytest/libpq/_core.py             |  467 +++++
 src/test/pytest/libpq/_error_base.py       |   74 +
 src/test/pytest/libpq/_generated_errors.py | 2116 ++++++++++++++++++++
 src/test/pytest/libpq/errors.py            |   39 +
 src/test/pytest/meson.build                |    4 +-
 src/test/pytest/pypg/__init__.py           |    4 +
 src/test/pytest/pypg/_env.py               |   54 +
 src/test/pytest/pypg/_win32.py             |  145 ++
 src/test/pytest/pypg/fixtures.py           |  191 ++
 src/test/pytest/pypg/server.py             |  391 ++++
 src/test/pytest/pypg/util.py               |   42 +
 src/test/pytest/pyt/conftest.py            |    4 +
 src/test/pytest/pyt/test_errors.py         |   34 +
 src/test/pytest/pyt/test_libpq.py          |  172 ++
 src/test/pytest/pyt/test_query_helpers.py  |  286 +++
 src/tools/generate_pytest_libpq_errors.py  |  147 ++
 19 files changed, 4213 insertions(+), 1 deletion(-)
 create mode 100644 src/test/pytest/libpq/__init__.py
 create mode 100644 src/test/pytest/libpq/_core.py
 create mode 100644 src/test/pytest/libpq/_error_base.py
 create mode 100644 src/test/pytest/libpq/_generated_errors.py
 create mode 100644 src/test/pytest/libpq/errors.py
 create mode 100644 src/test/pytest/pypg/__init__.py
 create mode 100644 src/test/pytest/pypg/_env.py
 create mode 100644 src/test/pytest/pypg/_win32.py
 create mode 100644 src/test/pytest/pypg/fixtures.py
 create mode 100644 src/test/pytest/pypg/server.py
 create mode 100644 src/test/pytest/pypg/util.py
 create mode 100644 src/test/pytest/pyt/conftest.py
 create mode 100644 src/test/pytest/pyt/test_errors.py
 create mode 100644 src/test/pytest/pyt/test_libpq.py
 create mode 100644 src/test/pytest/pyt/test_query_helpers.py
 create mode 100755 src/tools/generate_pytest_libpq_errors.py

diff --git a/pytest.ini b/pytest.ini
index 8e8388f3afc..e7aa84f3a84 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -4,3 +4,6 @@ minversion = 7.0
 # Ignore ./config (which contains the configure-time check_pytest.py tests) by
 # default.
 addopts = --ignore ./config
+
+# Common test code can be found here.
+pythonpath = src/test/pytest
diff --git a/src/backend/utils/errcodes.txt b/src/backend/utils/errcodes.txt
index c96aa7c49ef..40c7555047e 100644
--- a/src/backend/utils/errcodes.txt
+++ b/src/backend/utils/errcodes.txt
@@ -21,6 +21,11 @@
 #   doc/src/sgml/errcodes-table.sgml
 #      a SGML table of error codes for inclusion in the documentation
 #
+#   src/test/pytest/libpq/_generated_errors.py
+#      Python exception classes for the pytest libpq wrapper
+#      Note: This needs to be manually regenerated by running
+#      src/tools/generate_pytest_libpq_errors.py
+#
 # The format of this file is one error code per line, with the following
 # whitespace-separated fields:
 #
diff --git a/src/test/pytest/libpq/__init__.py b/src/test/pytest/libpq/__init__.py
new file mode 100644
index 00000000000..cb4d18b6206
--- /dev/null
+++ b/src/test/pytest/libpq/__init__.py
@@ -0,0 +1,36 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+"""
+libpq testing utilities - ctypes bindings and helpers for PostgreSQL's libpq library.
+
+This module provides Python wrappers around libpq for use in pytest tests.
+"""
+
+from . import errors
+from .errors import LibpqError, LibpqWarning
+from ._core import (
+    ConnectionStatus,
+    DiagField,
+    ExecStatus,
+    PGconn,
+    PGresult,
+    connect,
+    connstr,
+    load_libpq_handle,
+    register_type_info,
+)
+
+__all__ = [
+    "errors",
+    "LibpqError",
+    "LibpqWarning",
+    "ConnectionStatus",
+    "DiagField",
+    "ExecStatus",
+    "PGconn",
+    "PGresult",
+    "connect",
+    "connstr",
+    "load_libpq_handle",
+    "register_type_info",
+]
diff --git a/src/test/pytest/libpq/_core.py b/src/test/pytest/libpq/_core.py
new file mode 100644
index 00000000000..4776f0ff47e
--- /dev/null
+++ b/src/test/pytest/libpq/_core.py
@@ -0,0 +1,467 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+"""
+Core libpq functionality - ctypes bindings and connection handling.
+"""
+
+import contextlib
+import ctypes
+import datetime
+import decimal
+import enum
+import json
+import platform
+import os
+import uuid
+from typing import Any, Callable, Dict, Optional
+
+from .errors import LibpqError, make_error
+
+
+# PG_DIAG field identifiers from postgres_ext.h
+class DiagField(enum.IntEnum):
+    SEVERITY = ord("S")
+    SEVERITY_NONLOCALIZED = ord("V")
+    SQLSTATE = ord("C")
+    MESSAGE_PRIMARY = ord("M")
+    MESSAGE_DETAIL = ord("D")
+    MESSAGE_HINT = ord("H")
+    STATEMENT_POSITION = ord("P")
+    INTERNAL_POSITION = ord("p")
+    INTERNAL_QUERY = ord("q")
+    CONTEXT = ord("W")
+    SCHEMA_NAME = ord("s")
+    TABLE_NAME = ord("t")
+    COLUMN_NAME = ord("c")
+    DATATYPE_NAME = ord("d")
+    CONSTRAINT_NAME = ord("n")
+    SOURCE_FILE = ord("F")
+    SOURCE_LINE = ord("L")
+    SOURCE_FUNCTION = ord("R")
+
+
+class ConnectionStatus(enum.IntEnum):
+    """PostgreSQL connection status codes from libpq."""
+
+    CONNECTION_OK = 0
+    CONNECTION_BAD = 1
+
+
+class ExecStatus(enum.IntEnum):
+    """PostgreSQL result status codes from PQresultStatus."""
+
+    PGRES_EMPTY_QUERY = 0
+    PGRES_COMMAND_OK = 1
+    PGRES_TUPLES_OK = 2
+    PGRES_COPY_OUT = 3
+    PGRES_COPY_IN = 4
+    PGRES_BAD_RESPONSE = 5
+    PGRES_NONFATAL_ERROR = 6
+    PGRES_FATAL_ERROR = 7
+    PGRES_COPY_BOTH = 8
+    PGRES_SINGLE_TUPLE = 9
+    PGRES_PIPELINE_SYNC = 10
+    PGRES_PIPELINE_ABORTED = 11
+
+
+class _PGconn(ctypes.Structure):
+    pass
+
+
+class _PGresult(ctypes.Structure):
+    pass
+
+
+_PGconn_p = ctypes.POINTER(_PGconn)
+_PGresult_p = ctypes.POINTER(_PGresult)
+
+
+def load_libpq_handle(libdir, bindir):
+    """
+    Loads a ctypes handle for libpq. Some common function prototypes are
+    initialized for general use.
+    """
+    system = platform.system()
+
+    if system in ("Linux", "FreeBSD", "NetBSD", "OpenBSD"):
+        name = "libpq.so.5"
+    elif system == "Darwin":
+        name = "libpq.5.dylib"
+    elif system == "Windows":
+        name = "libpq.dll"
+    else:
+        assert False, f"the libpq fixture must be updated for {system}"
+
+    if system == "Windows":
+        # On Windows, libpq.dll is confusingly in bindir, not libdir. And we
+        # need to add this directory the the search path.
+        libpq_path = os.path.join(bindir, name)
+        lib = ctypes.CDLL(libpq_path)
+    else:
+        libpq_path = os.path.join(libdir, name)
+        lib = ctypes.CDLL(libpq_path)
+
+    #
+    # Function Prototypes
+    #
+
+    lib.PQconnectdb.restype = _PGconn_p
+    lib.PQconnectdb.argtypes = [ctypes.c_char_p]
+
+    lib.PQstatus.restype = ctypes.c_int
+    lib.PQstatus.argtypes = [_PGconn_p]
+
+    lib.PQexec.restype = _PGresult_p
+    lib.PQexec.argtypes = [_PGconn_p, ctypes.c_char_p]
+
+    lib.PQresultStatus.restype = ctypes.c_int
+    lib.PQresultStatus.argtypes = [_PGresult_p]
+
+    lib.PQclear.restype = None
+    lib.PQclear.argtypes = [_PGresult_p]
+
+    lib.PQerrorMessage.restype = ctypes.c_char_p
+    lib.PQerrorMessage.argtypes = [_PGconn_p]
+
+    lib.PQfinish.restype = None
+    lib.PQfinish.argtypes = [_PGconn_p]
+
+    lib.PQresultErrorMessage.restype = ctypes.c_char_p
+    lib.PQresultErrorMessage.argtypes = [_PGresult_p]
+
+    lib.PQntuples.restype = ctypes.c_int
+    lib.PQntuples.argtypes = [_PGresult_p]
+
+    lib.PQnfields.restype = ctypes.c_int
+    lib.PQnfields.argtypes = [_PGresult_p]
+
+    lib.PQgetvalue.restype = ctypes.c_char_p
+    lib.PQgetvalue.argtypes = [_PGresult_p, ctypes.c_int, ctypes.c_int]
+
+    lib.PQgetisnull.restype = ctypes.c_int
+    lib.PQgetisnull.argtypes = [_PGresult_p, ctypes.c_int, ctypes.c_int]
+
+    lib.PQftype.restype = ctypes.c_uint
+    lib.PQftype.argtypes = [_PGresult_p, ctypes.c_int]
+
+    lib.PQresultErrorField.restype = ctypes.c_char_p
+    lib.PQresultErrorField.argtypes = [_PGresult_p, ctypes.c_int]
+
+    return lib
+
+
+# PostgreSQL type OIDs and conversion system
+# Type registry - maps OID to converter function
+_type_converters: Dict[int, Callable[[str], Any]] = {}
+_array_to_elem_map: Dict[int, int] = {}
+
+
+def register_type_info(
+    name: str, oid: int, array_oid: int, converter: Callable[[str], Any]
+):
+    """
+    Register a PostgreSQL type with its OID, array OID, and conversion function.
+
+    Usage:
+        register_type_info("bool", 16, 1000, lambda v: v == "t")
+    """
+    _type_converters[oid] = converter
+    if array_oid is not None:
+        _array_to_elem_map[array_oid] = oid
+
+
+# Helper converters
+def _parse_array(value: str, elem_oid: int) -> list:
+    """Parse PostgreSQL array syntax: {elem1,elem2,elem3}"""
+    if not (value.startswith("{") and value.endswith("}")):
+        return value
+
+    inner = value[1:-1]
+    if not inner:
+        return []
+
+    elements = inner.split(",")
+    result = []
+    for elem in elements:
+        elem = elem.strip()
+        if elem == "NULL":
+            result.append(None)
+        else:
+            # Remove quotes if present
+            if elem.startswith('"') and elem.endswith('"'):
+                elem = elem[1:-1]
+            result.append(_convert_pg_value(elem, elem_oid))
+
+    return result
+
+
+# Register standard PostgreSQL types that we'll likely encounter in tests
+register_type_info("bool", 16, 1000, lambda v: v == "t")
+register_type_info("int2", 21, 1005, int)
+register_type_info("int4", 23, 1007, int)
+register_type_info("int8", 20, 1016, int)
+register_type_info("float4", 700, 1021, float)
+register_type_info("float8", 701, 1022, float)
+register_type_info("numeric", 1700, 1231, decimal.Decimal)
+register_type_info("text", 25, 1009, str)
+register_type_info("varchar", 1043, 1015, str)
+register_type_info("date", 1082, 1182, datetime.date.fromisoformat)
+register_type_info("time", 1083, 1183, datetime.time.fromisoformat)
+register_type_info("timestamp", 1114, 1115, datetime.datetime.fromisoformat)
+register_type_info("timestamptz", 1184, 1185, datetime.datetime.fromisoformat)
+register_type_info("uuid", 2950, 2951, uuid.UUID)
+register_type_info("json", 114, 199, json.loads)
+register_type_info("jsonb", 3802, 3807, json.loads)
+
+
+def _convert_pg_value(value: str, type_oid: int) -> Any:
+    """
+    Convert PostgreSQL string value to appropriate Python type based on OID.
+    Uses the registered type converters from register_type_info().
+    """
+    # Check if it's an array type
+    if type_oid in _array_to_elem_map:
+        elem_oid = _array_to_elem_map[type_oid]
+        return _parse_array(value, elem_oid)
+
+    # Use registered converter if available
+    converter = _type_converters.get(type_oid)
+    if converter:
+        return converter(value)
+
+    # Unknown types - return as string
+    return value
+
+
+def simplify_query_results(results) -> Any:
+    """
+    Simplify the results of a query so that the caller doesn't have to unpack
+    lists and tuples of length 1.
+    """
+    if len(results) == 1:
+        row = results[0]
+        if len(row) == 1:
+            # If there's only a single cell, just return the value
+            return row[0]
+        # If there's only a single row, just return that row
+        return row
+
+    if len(results) != 0 and len(results[0]) == 1:
+        # If there's only a single column, return an array of values
+        return [row[0] for row in results]
+
+    # if there are multiple rows and columns, return the results as is
+    return results
+
+
+class PGresult(contextlib.AbstractContextManager):
+    """Wraps a raw _PGresult_p with a more friendly interface."""
+
+    def __init__(self, lib: ctypes.CDLL, res: _PGresult_p):
+        self._lib = lib
+        self._res = res
+
+    def __exit__(self, *exc):
+        self._lib.PQclear(self._res)
+        self._res = None
+
+    def status(self) -> ExecStatus:
+        return ExecStatus(self._lib.PQresultStatus(self._res))
+
+    def error_message(self):
+        """Returns the error message associated with this result."""
+        msg = self._lib.PQresultErrorMessage(self._res)
+        return msg.decode() if msg else ""
+
+    def _get_error_field(self, field: DiagField) -> Optional[str]:
+        """Get an error field from the result using PQresultErrorField."""
+        val = self._lib.PQresultErrorField(self._res, int(field))
+        return val.decode() if val else None
+
+    def raise_error(self, query: Optional[str] = None) -> None:
+        """
+        Raises an appropriate LibpqError subclass based on the error fields.
+        Extracts SQLSTATE and other diagnostic information from the result.
+        """
+        sqlstate = self._get_error_field(DiagField.SQLSTATE)
+        primary = self._get_error_field(DiagField.MESSAGE_PRIMARY)
+        detail = self._get_error_field(DiagField.MESSAGE_DETAIL)
+        hint = self._get_error_field(DiagField.MESSAGE_HINT)
+        severity = self._get_error_field(DiagField.SEVERITY)
+        schema_name = self._get_error_field(DiagField.SCHEMA_NAME)
+        table_name = self._get_error_field(DiagField.TABLE_NAME)
+        column_name = self._get_error_field(DiagField.COLUMN_NAME)
+        datatype_name = self._get_error_field(DiagField.DATATYPE_NAME)
+        constraint_name = self._get_error_field(DiagField.CONSTRAINT_NAME)
+        context = self._get_error_field(DiagField.CONTEXT)
+
+        position_str = self._get_error_field(DiagField.STATEMENT_POSITION)
+        position = int(position_str) if position_str else None
+
+        # Build the error message
+        message = primary or self.error_message()
+        if query:
+            message = f"{message}\nQuery: {query}"
+
+        raise make_error(
+            message,
+            sqlstate=sqlstate,
+            severity=severity,
+            primary=primary,
+            detail=detail,
+            hint=hint,
+            schema_name=schema_name,
+            table_name=table_name,
+            column_name=column_name,
+            datatype_name=datatype_name,
+            constraint_name=constraint_name,
+            position=position,
+            context=context,
+        )
+
+    def fetch_all(self):
+        """
+        Fetch all rows and convert to Python types.
+        Returns a list of tuples, with values converted based on their PostgreSQL type.
+        """
+        nrows = self._lib.PQntuples(self._res)
+        ncols = self._lib.PQnfields(self._res)
+
+        # Get type OIDs for each column
+        type_oids = [self._lib.PQftype(self._res, col) for col in range(ncols)]
+
+        results = []
+        for row in range(nrows):
+            row_data = []
+            for col in range(ncols):
+                if self._lib.PQgetisnull(self._res, row, col):
+                    row_data.append(None)
+                else:
+                    value = self._lib.PQgetvalue(self._res, row, col).decode()
+                    row_data.append(_convert_pg_value(value, type_oids[col]))
+            results.append(tuple(row_data))
+
+        return results
+
+
+class PGconn(contextlib.AbstractContextManager):
+    """
+    Wraps a raw _PGconn_p with a more friendly interface. This is just a
+    stub; it's expected to grow.
+    """
+
+    def __init__(
+        self,
+        lib: ctypes.CDLL,
+        handle: _PGconn_p,
+        stack: contextlib.ExitStack,
+    ):
+        self._lib = lib
+        self._handle = handle
+        self._stack = stack
+
+    def __exit__(self, *exc):
+        self._lib.PQfinish(self._handle)
+        self._handle = None
+
+    def exec(self, query: str):
+        """
+        Executes a query via PQexec() and returns a PGresult.
+        """
+        res = self._lib.PQexec(self._handle, query.encode())
+        return self._stack.enter_context(PGresult(self._lib, res))
+
+    def sql(self, query: str):
+        """
+        Executes a query and raises an exception if it fails.
+        Returns the query results with automatic type conversion and simplification.
+        For commands that don't return data (INSERT, UPDATE, etc.), returns None.
+
+        Examples:
+        - SELECT 1 -> 1
+        - SELECT 1, 2 -> (1, 2)
+        - SELECT * FROM generate_series(1, 3) -> [1, 2, 3]
+        - SELECT * FROM (VALUES (1, 'a'), (2, 'b')) t -> [(1, 'a'), (2, 'b')]
+        - CREATE TABLE ... -> None
+        - INSERT INTO ... -> None
+        """
+        res = self.exec(query)
+        status = res.status()
+
+        if status == ExecStatus.PGRES_FATAL_ERROR:
+            res.raise_error(query)
+        elif status == ExecStatus.PGRES_COMMAND_OK:
+            return None
+        elif status == ExecStatus.PGRES_TUPLES_OK:
+            results = res.fetch_all()
+            return simplify_query_results(results)
+        else:
+            res.raise_error(query)
+
+
+def connstr(opts: Dict[str, Any]) -> str:
+    """
+    Flattens the provided options into a libpq connection string. Values
+    are converted to str and quoted/escaped as necessary.
+    """
+    settings = []
+
+    for k, v in opts.items():
+        v = str(v)
+        if not v:
+            v = "''"
+        else:
+            v = v.replace("\\", "\\\\")
+            v = v.replace("'", "\\'")
+
+            if " " in v:
+                v = f"'{v}'"
+
+        settings.append(f"{k}={v}")
+
+    return " ".join(settings)
+
+
+def connect(
+    libpq_handle: ctypes.CDLL,
+    stack: contextlib.ExitStack,
+    remaining_timeout_fn: Callable[[], float],
+    **opts,
+) -> PGconn:
+    """
+    Connects to a server, using the given connection options, and
+    returns a PGconn object wrapping the connection handle. A
+    failure will raise LibpqError.
+
+    Connections honor PG_TEST_TIMEOUT_DEFAULT unless connect_timeout is
+    explicitly overridden in opts.
+
+    Args:
+        libpq_handle: ctypes.CDLL handle to libpq library
+        stack: ExitStack for managing connection cleanup
+        remaining_timeout_fn: Function that returns remaining timeout in seconds
+        **opts: Connection options (host, port, dbname, etc.)
+
+    Returns:
+        PGconn: Connected database connection
+
+    Raises:
+        LibpqError: If connection fails
+    """
+
+    if "connect_timeout" not in opts:
+        t = int(remaining_timeout_fn())
+        opts["connect_timeout"] = max(t, 1)
+
+    conn_p = libpq_handle.PQconnectdb(connstr(opts).encode())
+
+    # Check connection status before adding to stack
+    if libpq_handle.PQstatus(conn_p) != ConnectionStatus.CONNECTION_OK:
+        error_msg = libpq_handle.PQerrorMessage(conn_p).decode()
+        # Manually close the failed connection
+        libpq_handle.PQfinish(conn_p)
+        raise LibpqError(error_msg)
+
+    # Connection succeeded - add to stack for cleanup
+    conn = stack.enter_context(PGconn(libpq_handle, conn_p, stack=stack))
+    return conn
diff --git a/src/test/pytest/libpq/_error_base.py b/src/test/pytest/libpq/_error_base.py
new file mode 100644
index 00000000000..5c70c077193
--- /dev/null
+++ b/src/test/pytest/libpq/_error_base.py
@@ -0,0 +1,74 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+"""
+Base exception classes for libpq errors and warnings.
+"""
+
+from typing import Optional
+
+
+class LibpqExceptionMixin:
+    """Mixin providing PostgreSQL error field attributes."""
+
+    sqlstate: Optional[str]
+    severity: Optional[str]
+    primary: Optional[str]
+    detail: Optional[str]
+    hint: Optional[str]
+    schema_name: Optional[str]
+    table_name: Optional[str]
+    column_name: Optional[str]
+    datatype_name: Optional[str]
+    constraint_name: Optional[str]
+    position: Optional[int]
+    context: Optional[str]
+
+    def __init__(
+        self,
+        message: str,
+        *,
+        sqlstate: Optional[str] = None,
+        severity: Optional[str] = None,
+        primary: Optional[str] = None,
+        detail: Optional[str] = None,
+        hint: Optional[str] = None,
+        schema_name: Optional[str] = None,
+        table_name: Optional[str] = None,
+        column_name: Optional[str] = None,
+        datatype_name: Optional[str] = None,
+        constraint_name: Optional[str] = None,
+        position: Optional[int] = None,
+        context: Optional[str] = None,
+    ):
+        super().__init__(message)
+        self.sqlstate = sqlstate
+        self.severity = severity
+        self.primary = primary
+        self.detail = detail
+        self.hint = hint
+        self.schema_name = schema_name
+        self.table_name = table_name
+        self.column_name = column_name
+        self.datatype_name = datatype_name
+        self.constraint_name = constraint_name
+        self.position = position
+        self.context = context
+
+    @property
+    def sqlstate_class(self) -> Optional[str]:
+        """Returns the 2-character SQLSTATE class."""
+        if self.sqlstate and len(self.sqlstate) >= 2:
+            return self.sqlstate[:2]
+        return None
+
+
+class LibpqError(LibpqExceptionMixin, RuntimeError):
+    """Base exception for libpq errors."""
+
+    pass
+
+
+class LibpqWarning(LibpqExceptionMixin, UserWarning):
+    """Base exception for libpq warnings."""
+
+    pass
diff --git a/src/test/pytest/libpq/_generated_errors.py b/src/test/pytest/libpq/_generated_errors.py
new file mode 100644
index 00000000000..f50f3143580
--- /dev/null
+++ b/src/test/pytest/libpq/_generated_errors.py
@@ -0,0 +1,2116 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+# This file is generated by src/tools/generate_pytest_libpq_errors.py - do not edit directly.
+
+"""
+Generated PostgreSQL error classes mapped from SQLSTATE codes.
+"""
+
+from typing import Dict
+
+from ._error_base import LibpqError, LibpqWarning
+
+
+class SuccessfulCompletion(LibpqError):
+    """SQLSTATE 00000 - successful completion."""
+
+    pass
+
+
+class Warning(LibpqWarning):
+    """SQLSTATE 01000 - warning."""
+
+    pass
+
+
+class DynamicResultSetsReturnedWarning(Warning):
+    """SQLSTATE 0100C - dynamic result sets returned."""
+
+    pass
+
+
+class ImplicitZeroBitPaddingWarning(Warning):
+    """SQLSTATE 01008 - implicit zero bit padding."""
+
+    pass
+
+
+class NullValueEliminatedInSetFunctionWarning(Warning):
+    """SQLSTATE 01003 - null value eliminated in set function."""
+
+    pass
+
+
+class PrivilegeNotGrantedWarning(Warning):
+    """SQLSTATE 01007 - privilege not granted."""
+
+    pass
+
+
+class PrivilegeNotRevokedWarning(Warning):
+    """SQLSTATE 01006 - privilege not revoked."""
+
+    pass
+
+
+class StringDataRightTruncationWarning(Warning):
+    """SQLSTATE 01004 - string data right truncation."""
+
+    pass
+
+
+class DeprecatedFeatureWarning(Warning):
+    """SQLSTATE 01P01 - deprecated feature."""
+
+    pass
+
+
+class NoData(LibpqError):
+    """SQLSTATE 02000 - no data."""
+
+    pass
+
+
+class NoAdditionalDynamicResultSetsReturned(NoData):
+    """SQLSTATE 02001 - no additional dynamic result sets returned."""
+
+    pass
+
+
+class SQLStatementNotYetComplete(LibpqError):
+    """SQLSTATE 03000 - sql statement not yet complete."""
+
+    pass
+
+
+class ConnectionException(LibpqError):
+    """SQLSTATE 08000 - connection exception."""
+
+    pass
+
+
+class ConnectionDoesNotExist(ConnectionException):
+    """SQLSTATE 08003 - connection does not exist."""
+
+    pass
+
+
+class ConnectionFailure(ConnectionException):
+    """SQLSTATE 08006 - connection failure."""
+
+    pass
+
+
+class SQLClientUnableToEstablishSQLConnection(ConnectionException):
+    """SQLSTATE 08001 - sqlclient unable to establish sqlconnection."""
+
+    pass
+
+
+class SQLServerRejectedEstablishmentOfSQLConnection(ConnectionException):
+    """SQLSTATE 08004 - sqlserver rejected establishment of sqlconnection."""
+
+    pass
+
+
+class TransactionResolutionUnknown(ConnectionException):
+    """SQLSTATE 08007 - transaction resolution unknown."""
+
+    pass
+
+
+class ProtocolViolation(ConnectionException):
+    """SQLSTATE 08P01 - protocol violation."""
+
+    pass
+
+
+class TriggeredActionException(LibpqError):
+    """SQLSTATE 09000 - triggered action exception."""
+
+    pass
+
+
+class FeatureNotSupported(LibpqError):
+    """SQLSTATE 0A000 - feature not supported."""
+
+    pass
+
+
+class InvalidTransactionInitiation(LibpqError):
+    """SQLSTATE 0B000 - invalid transaction initiation."""
+
+    pass
+
+
+class LocatorException(LibpqError):
+    """SQLSTATE 0F000 - locator exception."""
+
+    pass
+
+
+class InvalidLocatorSpecification(LocatorException):
+    """SQLSTATE 0F001 - invalid locator specification."""
+
+    pass
+
+
+class InvalidGrantor(LibpqError):
+    """SQLSTATE 0L000 - invalid grantor."""
+
+    pass
+
+
+class InvalidGrantOperation(InvalidGrantor):
+    """SQLSTATE 0LP01 - invalid grant operation."""
+
+    pass
+
+
+class InvalidRoleSpecification(LibpqError):
+    """SQLSTATE 0P000 - invalid role specification."""
+
+    pass
+
+
+class DiagnosticsException(LibpqError):
+    """SQLSTATE 0Z000 - diagnostics exception."""
+
+    pass
+
+
+class StackedDiagnosticsAccessedWithoutActiveHandler(DiagnosticsException):
+    """SQLSTATE 0Z002 - stacked diagnostics accessed without active handler."""
+
+    pass
+
+
+class InvalidArgumentForXquery(LibpqError):
+    """SQLSTATE 10608 - invalid argument for xquery."""
+
+    pass
+
+
+class CaseNotFound(LibpqError):
+    """SQLSTATE 20000 - case not found."""
+
+    pass
+
+
+class CardinalityViolation(LibpqError):
+    """SQLSTATE 21000 - cardinality violation."""
+
+    pass
+
+
+class DataException(LibpqError):
+    """SQLSTATE 22000 - data exception."""
+
+    pass
+
+
+class ArraySubscriptError(DataException):
+    """SQLSTATE 2202E - array subscript error."""
+
+    pass
+
+
+class CharacterNotInRepertoire(DataException):
+    """SQLSTATE 22021 - character not in repertoire."""
+
+    pass
+
+
+class DatetimeFieldOverflow(DataException):
+    """SQLSTATE 22008 - datetime field overflow."""
+
+    pass
+
+
+class DivisionByZero(DataException):
+    """SQLSTATE 22012 - division by zero."""
+
+    pass
+
+
+class ErrorInAssignment(DataException):
+    """SQLSTATE 22005 - error in assignment."""
+
+    pass
+
+
+class EscapeCharacterConflict(DataException):
+    """SQLSTATE 2200B - escape character conflict."""
+
+    pass
+
+
+class IndicatorOverflow(DataException):
+    """SQLSTATE 22022 - indicator overflow."""
+
+    pass
+
+
+class IntervalFieldOverflow(DataException):
+    """SQLSTATE 22015 - interval field overflow."""
+
+    pass
+
+
+class InvalidArgumentForLogarithm(DataException):
+    """SQLSTATE 2201E - invalid argument for logarithm."""
+
+    pass
+
+
+class InvalidArgumentForNtileFunction(DataException):
+    """SQLSTATE 22014 - invalid argument for ntile function."""
+
+    pass
+
+
+class InvalidArgumentForNthValueFunction(DataException):
+    """SQLSTATE 22016 - invalid argument for nth value function."""
+
+    pass
+
+
+class InvalidArgumentForPowerFunction(DataException):
+    """SQLSTATE 2201F - invalid argument for power function."""
+
+    pass
+
+
+class InvalidArgumentForWidthBucketFunction(DataException):
+    """SQLSTATE 2201G - invalid argument for width bucket function."""
+
+    pass
+
+
+class InvalidCharacterValueForCast(DataException):
+    """SQLSTATE 22018 - invalid character value for cast."""
+
+    pass
+
+
+class InvalidDatetimeFormat(DataException):
+    """SQLSTATE 22007 - invalid datetime format."""
+
+    pass
+
+
+class InvalidEscapeCharacter(DataException):
+    """SQLSTATE 22019 - invalid escape character."""
+
+    pass
+
+
+class InvalidEscapeOctet(DataException):
+    """SQLSTATE 2200D - invalid escape octet."""
+
+    pass
+
+
+class InvalidEscapeSequence(DataException):
+    """SQLSTATE 22025 - invalid escape sequence."""
+
+    pass
+
+
+class NonstandardUseOfEscapeCharacter(DataException):
+    """SQLSTATE 22P06 - nonstandard use of escape character."""
+
+    pass
+
+
+class InvalidIndicatorParameterValue(DataException):
+    """SQLSTATE 22010 - invalid indicator parameter value."""
+
+    pass
+
+
+class InvalidParameterValue(DataException):
+    """SQLSTATE 22023 - invalid parameter value."""
+
+    pass
+
+
+class InvalidPrecedingOrFollowingSize(DataException):
+    """SQLSTATE 22013 - invalid preceding or following size."""
+
+    pass
+
+
+class InvalidRegularExpression(DataException):
+    """SQLSTATE 2201B - invalid regular expression."""
+
+    pass
+
+
+class InvalidRowCountInLimitClause(DataException):
+    """SQLSTATE 2201W - invalid row count in limit clause."""
+
+    pass
+
+
+class InvalidRowCountInResultOffsetClause(DataException):
+    """SQLSTATE 2201X - invalid row count in result offset clause."""
+
+    pass
+
+
+class InvalidTablesampleArgument(DataException):
+    """SQLSTATE 2202H - invalid tablesample argument."""
+
+    pass
+
+
+class InvalidTablesampleRepeat(DataException):
+    """SQLSTATE 2202G - invalid tablesample repeat."""
+
+    pass
+
+
+class InvalidTimeZoneDisplacementValue(DataException):
+    """SQLSTATE 22009 - invalid time zone displacement value."""
+
+    pass
+
+
+class InvalidUseOfEscapeCharacter(DataException):
+    """SQLSTATE 2200C - invalid use of escape character."""
+
+    pass
+
+
+class MostSpecificTypeMismatch(DataException):
+    """SQLSTATE 2200G - most specific type mismatch."""
+
+    pass
+
+
+class NullValueNotAllowed(DataException):
+    """SQLSTATE 22004 - null value not allowed."""
+
+    pass
+
+
+class NullValueNoIndicatorParameter(DataException):
+    """SQLSTATE 22002 - null value no indicator parameter."""
+
+    pass
+
+
+class NumericValueOutOfRange(DataException):
+    """SQLSTATE 22003 - numeric value out of range."""
+
+    pass
+
+
+class SequenceGeneratorLimitExceeded(DataException):
+    """SQLSTATE 2200H - sequence generator limit exceeded."""
+
+    pass
+
+
+class StringDataLengthMismatch(DataException):
+    """SQLSTATE 22026 - string data length mismatch."""
+
+    pass
+
+
+class StringDataRightTruncation(DataException):
+    """SQLSTATE 22001 - string data right truncation."""
+
+    pass
+
+
+class SubstringError(DataException):
+    """SQLSTATE 22011 - substring error."""
+
+    pass
+
+
+class TrimError(DataException):
+    """SQLSTATE 22027 - trim error."""
+
+    pass
+
+
+class UnterminatedCString(DataException):
+    """SQLSTATE 22024 - unterminated c string."""
+
+    pass
+
+
+class ZeroLengthCharacterString(DataException):
+    """SQLSTATE 2200F - zero length character string."""
+
+    pass
+
+
+class FloatingPointException(DataException):
+    """SQLSTATE 22P01 - floating point exception."""
+
+    pass
+
+
+class InvalidTextRepresentation(DataException):
+    """SQLSTATE 22P02 - invalid text representation."""
+
+    pass
+
+
+class InvalidBinaryRepresentation(DataException):
+    """SQLSTATE 22P03 - invalid binary representation."""
+
+    pass
+
+
+class BadCopyFileFormat(DataException):
+    """SQLSTATE 22P04 - bad copy file format."""
+
+    pass
+
+
+class UntranslatableCharacter(DataException):
+    """SQLSTATE 22P05 - untranslatable character."""
+
+    pass
+
+
+class NotAnXmlDocument(DataException):
+    """SQLSTATE 2200L - not an xml document."""
+
+    pass
+
+
+class InvalidXmlDocument(DataException):
+    """SQLSTATE 2200M - invalid xml document."""
+
+    pass
+
+
+class InvalidXmlContent(DataException):
+    """SQLSTATE 2200N - invalid xml content."""
+
+    pass
+
+
+class InvalidXmlComment(DataException):
+    """SQLSTATE 2200S - invalid xml comment."""
+
+    pass
+
+
+class InvalidXmlProcessingInstruction(DataException):
+    """SQLSTATE 2200T - invalid xml processing instruction."""
+
+    pass
+
+
+class DuplicateJsonObjectKeyValue(DataException):
+    """SQLSTATE 22030 - duplicate json object key value."""
+
+    pass
+
+
+class InvalidArgumentForSQLJsonDatetimeFunction(DataException):
+    """SQLSTATE 22031 - invalid argument for sql json datetime function."""
+
+    pass
+
+
+class InvalidJsonText(DataException):
+    """SQLSTATE 22032 - invalid json text."""
+
+    pass
+
+
+class InvalidSQLJsonSubscript(DataException):
+    """SQLSTATE 22033 - invalid sql json subscript."""
+
+    pass
+
+
+class MoreThanOneSQLJsonItem(DataException):
+    """SQLSTATE 22034 - more than one sql json item."""
+
+    pass
+
+
+class NoSQLJsonItem(DataException):
+    """SQLSTATE 22035 - no sql json item."""
+
+    pass
+
+
+class NonNumericSQLJsonItem(DataException):
+    """SQLSTATE 22036 - non numeric sql json item."""
+
+    pass
+
+
+class NonUniqueKeysInAJsonObject(DataException):
+    """SQLSTATE 22037 - non unique keys in a json object."""
+
+    pass
+
+
+class SingletonSQLJsonItemRequired(DataException):
+    """SQLSTATE 22038 - singleton sql json item required."""
+
+    pass
+
+
+class SQLJsonArrayNotFound(DataException):
+    """SQLSTATE 22039 - sql json array not found."""
+
+    pass
+
+
+class SQLJsonMemberNotFound(DataException):
+    """SQLSTATE 2203A - sql json member not found."""
+
+    pass
+
+
+class SQLJsonNumberNotFound(DataException):
+    """SQLSTATE 2203B - sql json number not found."""
+
+    pass
+
+
+class SQLJsonObjectNotFound(DataException):
+    """SQLSTATE 2203C - sql json object not found."""
+
+    pass
+
+
+class TooManyJsonArrayElements(DataException):
+    """SQLSTATE 2203D - too many json array elements."""
+
+    pass
+
+
+class TooManyJsonObjectMembers(DataException):
+    """SQLSTATE 2203E - too many json object members."""
+
+    pass
+
+
+class SQLJsonScalarRequired(DataException):
+    """SQLSTATE 2203F - sql json scalar required."""
+
+    pass
+
+
+class SQLJsonItemCannotBeCastToTargetType(DataException):
+    """SQLSTATE 2203G - sql json item cannot be cast to target type."""
+
+    pass
+
+
+class IntegrityConstraintViolation(LibpqError):
+    """SQLSTATE 23000 - integrity constraint violation."""
+
+    pass
+
+
+class RestrictViolation(IntegrityConstraintViolation):
+    """SQLSTATE 23001 - restrict violation."""
+
+    pass
+
+
+class NotNullViolation(IntegrityConstraintViolation):
+    """SQLSTATE 23502 - not null violation."""
+
+    pass
+
+
+class ForeignKeyViolation(IntegrityConstraintViolation):
+    """SQLSTATE 23503 - foreign key violation."""
+
+    pass
+
+
+class UniqueViolation(IntegrityConstraintViolation):
+    """SQLSTATE 23505 - unique violation."""
+
+    pass
+
+
+class CheckViolation(IntegrityConstraintViolation):
+    """SQLSTATE 23514 - check violation."""
+
+    pass
+
+
+class ExclusionViolation(IntegrityConstraintViolation):
+    """SQLSTATE 23P01 - exclusion violation."""
+
+    pass
+
+
+class InvalidCursorState(LibpqError):
+    """SQLSTATE 24000 - invalid cursor state."""
+
+    pass
+
+
+class InvalidTransactionState(LibpqError):
+    """SQLSTATE 25000 - invalid transaction state."""
+
+    pass
+
+
+class ActiveSQLTransaction(InvalidTransactionState):
+    """SQLSTATE 25001 - active sql transaction."""
+
+    pass
+
+
+class BranchTransactionAlreadyActive(InvalidTransactionState):
+    """SQLSTATE 25002 - branch transaction already active."""
+
+    pass
+
+
+class HeldCursorRequiresSameIsolationLevel(InvalidTransactionState):
+    """SQLSTATE 25008 - held cursor requires same isolation level."""
+
+    pass
+
+
+class InappropriateAccessModeForBranchTransaction(InvalidTransactionState):
+    """SQLSTATE 25003 - inappropriate access mode for branch transaction."""
+
+    pass
+
+
+class InappropriateIsolationLevelForBranchTransaction(InvalidTransactionState):
+    """SQLSTATE 25004 - inappropriate isolation level for branch transaction."""
+
+    pass
+
+
+class NoActiveSQLTransactionForBranchTransaction(InvalidTransactionState):
+    """SQLSTATE 25005 - no active sql transaction for branch transaction."""
+
+    pass
+
+
+class ReadOnlySQLTransaction(InvalidTransactionState):
+    """SQLSTATE 25006 - read only sql transaction."""
+
+    pass
+
+
+class SchemaAndDataStatementMixingNotSupported(InvalidTransactionState):
+    """SQLSTATE 25007 - schema and data statement mixing not supported."""
+
+    pass
+
+
+class NoActiveSQLTransaction(InvalidTransactionState):
+    """SQLSTATE 25P01 - no active sql transaction."""
+
+    pass
+
+
+class InFailedSQLTransaction(InvalidTransactionState):
+    """SQLSTATE 25P02 - in failed sql transaction."""
+
+    pass
+
+
+class IdleInTransactionSessionTimeout(InvalidTransactionState):
+    """SQLSTATE 25P03 - idle in transaction session timeout."""
+
+    pass
+
+
+class TransactionTimeout(InvalidTransactionState):
+    """SQLSTATE 25P04 - transaction timeout."""
+
+    pass
+
+
+class InvalidSQLStatementName(LibpqError):
+    """SQLSTATE 26000 - invalid sql statement name."""
+
+    pass
+
+
+class TriggeredDataChangeViolation(LibpqError):
+    """SQLSTATE 27000 - triggered data change violation."""
+
+    pass
+
+
+class InvalidAuthorizationSpecification(LibpqError):
+    """SQLSTATE 28000 - invalid authorization specification."""
+
+    pass
+
+
+class InvalidPassword(InvalidAuthorizationSpecification):
+    """SQLSTATE 28P01 - invalid password."""
+
+    pass
+
+
+class DependentPrivilegeDescriptorsStillExist(LibpqError):
+    """SQLSTATE 2B000 - dependent privilege descriptors still exist."""
+
+    pass
+
+
+class DependentObjectsStillExist(DependentPrivilegeDescriptorsStillExist):
+    """SQLSTATE 2BP01 - dependent objects still exist."""
+
+    pass
+
+
+class InvalidTransactionTermination(LibpqError):
+    """SQLSTATE 2D000 - invalid transaction termination."""
+
+    pass
+
+
+class SQLRoutineException(LibpqError):
+    """SQLSTATE 2F000 - sql routine exception."""
+
+    pass
+
+
+class FunctionExecutedNoReturnStatement(SQLRoutineException):
+    """SQLSTATE 2F005 - function executed no return statement."""
+
+    pass
+
+
+class SREModifyingSQLDataNotPermitted(SQLRoutineException):
+    """SQLSTATE 2F002 - modifying sql data not permitted."""
+
+    pass
+
+
+class SREProhibitedSQLStatementAttempted(SQLRoutineException):
+    """SQLSTATE 2F003 - prohibited sql statement attempted."""
+
+    pass
+
+
+class SREReadingSQLDataNotPermitted(SQLRoutineException):
+    """SQLSTATE 2F004 - reading sql data not permitted."""
+
+    pass
+
+
+class InvalidCursorName(LibpqError):
+    """SQLSTATE 34000 - invalid cursor name."""
+
+    pass
+
+
+class ExternalRoutineException(LibpqError):
+    """SQLSTATE 38000 - external routine exception."""
+
+    pass
+
+
+class ContainingSQLNotPermitted(ExternalRoutineException):
+    """SQLSTATE 38001 - containing sql not permitted."""
+
+    pass
+
+
+class EREModifyingSQLDataNotPermitted(ExternalRoutineException):
+    """SQLSTATE 38002 - modifying sql data not permitted."""
+
+    pass
+
+
+class EREProhibitedSQLStatementAttempted(ExternalRoutineException):
+    """SQLSTATE 38003 - prohibited sql statement attempted."""
+
+    pass
+
+
+class EREReadingSQLDataNotPermitted(ExternalRoutineException):
+    """SQLSTATE 38004 - reading sql data not permitted."""
+
+    pass
+
+
+class ExternalRoutineInvocationException(LibpqError):
+    """SQLSTATE 39000 - external routine invocation exception."""
+
+    pass
+
+
+class InvalidSqlstateReturned(ExternalRoutineInvocationException):
+    """SQLSTATE 39001 - invalid sqlstate returned."""
+
+    pass
+
+
+class ERIENullValueNotAllowed(ExternalRoutineInvocationException):
+    """SQLSTATE 39004 - null value not allowed."""
+
+    pass
+
+
+class TriggerProtocolViolated(ExternalRoutineInvocationException):
+    """SQLSTATE 39P01 - trigger protocol violated."""
+
+    pass
+
+
+class SrfProtocolViolated(ExternalRoutineInvocationException):
+    """SQLSTATE 39P02 - srf protocol violated."""
+
+    pass
+
+
+class EventTriggerProtocolViolated(ExternalRoutineInvocationException):
+    """SQLSTATE 39P03 - event trigger protocol violated."""
+
+    pass
+
+
+class SavepointException(LibpqError):
+    """SQLSTATE 3B000 - savepoint exception."""
+
+    pass
+
+
+class InvalidSavepointSpecification(SavepointException):
+    """SQLSTATE 3B001 - invalid savepoint specification."""
+
+    pass
+
+
+class InvalidCatalogName(LibpqError):
+    """SQLSTATE 3D000 - invalid catalog name."""
+
+    pass
+
+
+class InvalidSchemaName(LibpqError):
+    """SQLSTATE 3F000 - invalid schema name."""
+
+    pass
+
+
+class TransactionRollback(LibpqError):
+    """SQLSTATE 40000 - transaction rollback."""
+
+    pass
+
+
+class TransactionIntegrityConstraintViolation(TransactionRollback):
+    """SQLSTATE 40002 - transaction integrity constraint violation."""
+
+    pass
+
+
+class SerializationFailure(TransactionRollback):
+    """SQLSTATE 40001 - serialization failure."""
+
+    pass
+
+
+class StatementCompletionUnknown(TransactionRollback):
+    """SQLSTATE 40003 - statement completion unknown."""
+
+    pass
+
+
+class DeadlockDetected(TransactionRollback):
+    """SQLSTATE 40P01 - deadlock detected."""
+
+    pass
+
+
+class SyntaxErrorOrAccessRuleViolation(LibpqError):
+    """SQLSTATE 42000 - syntax error or access rule violation."""
+
+    pass
+
+
+class SyntaxError(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42601 - syntax error."""
+
+    pass
+
+
+class InsufficientPrivilege(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42501 - insufficient privilege."""
+
+    pass
+
+
+class CannotCoerce(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42846 - cannot coerce."""
+
+    pass
+
+
+class GroupingError(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42803 - grouping error."""
+
+    pass
+
+
+class WindowingError(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42P20 - windowing error."""
+
+    pass
+
+
+class InvalidRecursion(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42P19 - invalid recursion."""
+
+    pass
+
+
+class InvalidForeignKey(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42830 - invalid foreign key."""
+
+    pass
+
+
+class InvalidName(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42602 - invalid name."""
+
+    pass
+
+
+class NameTooLong(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42622 - name too long."""
+
+    pass
+
+
+class ReservedName(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42939 - reserved name."""
+
+    pass
+
+
+class DatatypeMismatch(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42804 - datatype mismatch."""
+
+    pass
+
+
+class IndeterminateDatatype(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42P18 - indeterminate datatype."""
+
+    pass
+
+
+class CollationMismatch(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42P21 - collation mismatch."""
+
+    pass
+
+
+class IndeterminateCollation(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42P22 - indeterminate collation."""
+
+    pass
+
+
+class WrongObjectType(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42809 - wrong object type."""
+
+    pass
+
+
+class GeneratedAlways(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 428C9 - generated always."""
+
+    pass
+
+
+class UndefinedColumn(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42703 - undefined column."""
+
+    pass
+
+
+class UndefinedFunction(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42883 - undefined function."""
+
+    pass
+
+
+class UndefinedTable(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42P01 - undefined table."""
+
+    pass
+
+
+class UndefinedParameter(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42P02 - undefined parameter."""
+
+    pass
+
+
+class UndefinedObject(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42704 - undefined object."""
+
+    pass
+
+
+class DuplicateColumn(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42701 - duplicate column."""
+
+    pass
+
+
+class DuplicateCursor(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42P03 - duplicate cursor."""
+
+    pass
+
+
+class DuplicateDatabase(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42P04 - duplicate database."""
+
+    pass
+
+
+class DuplicateFunction(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42723 - duplicate function."""
+
+    pass
+
+
+class DuplicatePreparedStatement(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42P05 - duplicate prepared statement."""
+
+    pass
+
+
+class DuplicateSchema(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42P06 - duplicate schema."""
+
+    pass
+
+
+class DuplicateTable(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42P07 - duplicate table."""
+
+    pass
+
+
+class DuplicateAlias(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42712 - duplicate alias."""
+
+    pass
+
+
+class DuplicateObject(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42710 - duplicate object."""
+
+    pass
+
+
+class AmbiguousColumn(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42702 - ambiguous column."""
+
+    pass
+
+
+class AmbiguousFunction(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42725 - ambiguous function."""
+
+    pass
+
+
+class AmbiguousParameter(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42P08 - ambiguous parameter."""
+
+    pass
+
+
+class AmbiguousAlias(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42P09 - ambiguous alias."""
+
+    pass
+
+
+class InvalidColumnReference(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42P10 - invalid column reference."""
+
+    pass
+
+
+class InvalidColumnDefinition(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42611 - invalid column definition."""
+
+    pass
+
+
+class InvalidCursorDefinition(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42P11 - invalid cursor definition."""
+
+    pass
+
+
+class InvalidDatabaseDefinition(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42P12 - invalid database definition."""
+
+    pass
+
+
+class InvalidFunctionDefinition(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42P13 - invalid function definition."""
+
+    pass
+
+
+class InvalidPreparedStatementDefinition(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42P14 - invalid prepared statement definition."""
+
+    pass
+
+
+class InvalidSchemaDefinition(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42P15 - invalid schema definition."""
+
+    pass
+
+
+class InvalidTableDefinition(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42P16 - invalid table definition."""
+
+    pass
+
+
+class InvalidObjectDefinition(SyntaxErrorOrAccessRuleViolation):
+    """SQLSTATE 42P17 - invalid object definition."""
+
+    pass
+
+
+class WithCheckOptionViolation(LibpqError):
+    """SQLSTATE 44000 - with check option violation."""
+
+    pass
+
+
+class InsufficientResources(LibpqError):
+    """SQLSTATE 53000 - insufficient resources."""
+
+    pass
+
+
+class DiskFull(InsufficientResources):
+    """SQLSTATE 53100 - disk full."""
+
+    pass
+
+
+class OutOfMemory(InsufficientResources):
+    """SQLSTATE 53200 - out of memory."""
+
+    pass
+
+
+class TooManyConnections(InsufficientResources):
+    """SQLSTATE 53300 - too many connections."""
+
+    pass
+
+
+class ConfigurationLimitExceeded(InsufficientResources):
+    """SQLSTATE 53400 - configuration limit exceeded."""
+
+    pass
+
+
+class ProgramLimitExceeded(LibpqError):
+    """SQLSTATE 54000 - program limit exceeded."""
+
+    pass
+
+
+class StatementTooComplex(ProgramLimitExceeded):
+    """SQLSTATE 54001 - statement too complex."""
+
+    pass
+
+
+class TooManyColumns(ProgramLimitExceeded):
+    """SQLSTATE 54011 - too many columns."""
+
+    pass
+
+
+class TooManyArguments(ProgramLimitExceeded):
+    """SQLSTATE 54023 - too many arguments."""
+
+    pass
+
+
+class ObjectNotInPrerequisiteState(LibpqError):
+    """SQLSTATE 55000 - object not in prerequisite state."""
+
+    pass
+
+
+class ObjectInUse(ObjectNotInPrerequisiteState):
+    """SQLSTATE 55006 - object in use."""
+
+    pass
+
+
+class CantChangeRuntimeParam(ObjectNotInPrerequisiteState):
+    """SQLSTATE 55P02 - cant change runtime param."""
+
+    pass
+
+
+class LockNotAvailable(ObjectNotInPrerequisiteState):
+    """SQLSTATE 55P03 - lock not available."""
+
+    pass
+
+
+class UnsafeNewEnumValueUsage(ObjectNotInPrerequisiteState):
+    """SQLSTATE 55P04 - unsafe new enum value usage."""
+
+    pass
+
+
+class OperatorIntervention(LibpqError):
+    """SQLSTATE 57000 - operator intervention."""
+
+    pass
+
+
+class QueryCanceled(OperatorIntervention):
+    """SQLSTATE 57014 - query canceled."""
+
+    pass
+
+
+class AdminShutdown(OperatorIntervention):
+    """SQLSTATE 57P01 - admin shutdown."""
+
+    pass
+
+
+class CrashShutdown(OperatorIntervention):
+    """SQLSTATE 57P02 - crash shutdown."""
+
+    pass
+
+
+class CannotConnectNow(OperatorIntervention):
+    """SQLSTATE 57P03 - cannot connect now."""
+
+    pass
+
+
+class DatabaseDropped(OperatorIntervention):
+    """SQLSTATE 57P04 - database dropped."""
+
+    pass
+
+
+class IdleSessionTimeout(OperatorIntervention):
+    """SQLSTATE 57P05 - idle session timeout."""
+
+    pass
+
+
+class SystemError(LibpqError):
+    """SQLSTATE 58000 - system error."""
+
+    pass
+
+
+class IoError(SystemError):
+    """SQLSTATE 58030 - io error."""
+
+    pass
+
+
+class UndefinedFile(SystemError):
+    """SQLSTATE 58P01 - undefined file."""
+
+    pass
+
+
+class DuplicateFile(SystemError):
+    """SQLSTATE 58P02 - duplicate file."""
+
+    pass
+
+
+class FileNameTooLong(SystemError):
+    """SQLSTATE 58P03 - file name too long."""
+
+    pass
+
+
+class ConfigFileError(LibpqError):
+    """SQLSTATE F0000 - config file error."""
+
+    pass
+
+
+class LockFileExists(ConfigFileError):
+    """SQLSTATE F0001 - lock file exists."""
+
+    pass
+
+
+class FDWError(LibpqError):
+    """SQLSTATE HV000 - fdw error."""
+
+    pass
+
+
+class FDWColumnNameNotFound(FDWError):
+    """SQLSTATE HV005 - fdw column name not found."""
+
+    pass
+
+
+class FDWDynamicParameterValueNeeded(FDWError):
+    """SQLSTATE HV002 - fdw dynamic parameter value needed."""
+
+    pass
+
+
+class FDWFunctionSequenceError(FDWError):
+    """SQLSTATE HV010 - fdw function sequence error."""
+
+    pass
+
+
+class FDWInconsistentDescriptorInformation(FDWError):
+    """SQLSTATE HV021 - fdw inconsistent descriptor information."""
+
+    pass
+
+
+class FDWInvalidAttributeValue(FDWError):
+    """SQLSTATE HV024 - fdw invalid attribute value."""
+
+    pass
+
+
+class FDWInvalidColumnName(FDWError):
+    """SQLSTATE HV007 - fdw invalid column name."""
+
+    pass
+
+
+class FDWInvalidColumnNumber(FDWError):
+    """SQLSTATE HV008 - fdw invalid column number."""
+
+    pass
+
+
+class FDWInvalidDataType(FDWError):
+    """SQLSTATE HV004 - fdw invalid data type."""
+
+    pass
+
+
+class FDWInvalidDataTypeDescriptors(FDWError):
+    """SQLSTATE HV006 - fdw invalid data type descriptors."""
+
+    pass
+
+
+class FDWInvalidDescriptorFieldIdentifier(FDWError):
+    """SQLSTATE HV091 - fdw invalid descriptor field identifier."""
+
+    pass
+
+
+class FDWInvalidHandle(FDWError):
+    """SQLSTATE HV00B - fdw invalid handle."""
+
+    pass
+
+
+class FDWInvalidOptionIndex(FDWError):
+    """SQLSTATE HV00C - fdw invalid option index."""
+
+    pass
+
+
+class FDWInvalidOptionName(FDWError):
+    """SQLSTATE HV00D - fdw invalid option name."""
+
+    pass
+
+
+class FDWInvalidStringLengthOrBufferLength(FDWError):
+    """SQLSTATE HV090 - fdw invalid string length or buffer length."""
+
+    pass
+
+
+class FDWInvalidStringFormat(FDWError):
+    """SQLSTATE HV00A - fdw invalid string format."""
+
+    pass
+
+
+class FDWInvalidUseOfNullPointer(FDWError):
+    """SQLSTATE HV009 - fdw invalid use of null pointer."""
+
+    pass
+
+
+class FDWTooManyHandles(FDWError):
+    """SQLSTATE HV014 - fdw too many handles."""
+
+    pass
+
+
+class FDWOutOfMemory(FDWError):
+    """SQLSTATE HV001 - fdw out of memory."""
+
+    pass
+
+
+class FDWNoSchemas(FDWError):
+    """SQLSTATE HV00P - fdw no schemas."""
+
+    pass
+
+
+class FDWOptionNameNotFound(FDWError):
+    """SQLSTATE HV00J - fdw option name not found."""
+
+    pass
+
+
+class FDWReplyHandle(FDWError):
+    """SQLSTATE HV00K - fdw reply handle."""
+
+    pass
+
+
+class FDWSchemaNotFound(FDWError):
+    """SQLSTATE HV00Q - fdw schema not found."""
+
+    pass
+
+
+class FDWTableNotFound(FDWError):
+    """SQLSTATE HV00R - fdw table not found."""
+
+    pass
+
+
+class FDWUnableToCreateExecution(FDWError):
+    """SQLSTATE HV00L - fdw unable to create execution."""
+
+    pass
+
+
+class FDWUnableToCreateReply(FDWError):
+    """SQLSTATE HV00M - fdw unable to create reply."""
+
+    pass
+
+
+class FDWUnableToEstablishConnection(FDWError):
+    """SQLSTATE HV00N - fdw unable to establish connection."""
+
+    pass
+
+
+class PlpgsqlError(LibpqError):
+    """SQLSTATE P0000 - plpgsql error."""
+
+    pass
+
+
+class RaiseException(PlpgsqlError):
+    """SQLSTATE P0001 - raise exception."""
+
+    pass
+
+
+class NoDataFound(PlpgsqlError):
+    """SQLSTATE P0002 - no data found."""
+
+    pass
+
+
+class TooManyRows(PlpgsqlError):
+    """SQLSTATE P0003 - too many rows."""
+
+    pass
+
+
+class AssertFailure(PlpgsqlError):
+    """SQLSTATE P0004 - assert failure."""
+
+    pass
+
+
+class InternalError(LibpqError):
+    """SQLSTATE XX000 - internal error."""
+
+    pass
+
+
+class DataCorrupted(InternalError):
+    """SQLSTATE XX001 - data corrupted."""
+
+    pass
+
+
+class IndexCorrupted(InternalError):
+    """SQLSTATE XX002 - index corrupted."""
+
+    pass
+
+
+SQLSTATE_TO_EXCEPTION: Dict[str, type] = {
+    "00000": SuccessfulCompletion,
+    "01000": Warning,
+    "0100C": DynamicResultSetsReturnedWarning,
+    "01008": ImplicitZeroBitPaddingWarning,
+    "01003": NullValueEliminatedInSetFunctionWarning,
+    "01007": PrivilegeNotGrantedWarning,
+    "01006": PrivilegeNotRevokedWarning,
+    "01004": StringDataRightTruncationWarning,
+    "01P01": DeprecatedFeatureWarning,
+    "02000": NoData,
+    "02001": NoAdditionalDynamicResultSetsReturned,
+    "03000": SQLStatementNotYetComplete,
+    "08000": ConnectionException,
+    "08003": ConnectionDoesNotExist,
+    "08006": ConnectionFailure,
+    "08001": SQLClientUnableToEstablishSQLConnection,
+    "08004": SQLServerRejectedEstablishmentOfSQLConnection,
+    "08007": TransactionResolutionUnknown,
+    "08P01": ProtocolViolation,
+    "09000": TriggeredActionException,
+    "0A000": FeatureNotSupported,
+    "0B000": InvalidTransactionInitiation,
+    "0F000": LocatorException,
+    "0F001": InvalidLocatorSpecification,
+    "0L000": InvalidGrantor,
+    "0LP01": InvalidGrantOperation,
+    "0P000": InvalidRoleSpecification,
+    "0Z000": DiagnosticsException,
+    "0Z002": StackedDiagnosticsAccessedWithoutActiveHandler,
+    "10608": InvalidArgumentForXquery,
+    "20000": CaseNotFound,
+    "21000": CardinalityViolation,
+    "22000": DataException,
+    "2202E": ArraySubscriptError,
+    "22021": CharacterNotInRepertoire,
+    "22008": DatetimeFieldOverflow,
+    "22012": DivisionByZero,
+    "22005": ErrorInAssignment,
+    "2200B": EscapeCharacterConflict,
+    "22022": IndicatorOverflow,
+    "22015": IntervalFieldOverflow,
+    "2201E": InvalidArgumentForLogarithm,
+    "22014": InvalidArgumentForNtileFunction,
+    "22016": InvalidArgumentForNthValueFunction,
+    "2201F": InvalidArgumentForPowerFunction,
+    "2201G": InvalidArgumentForWidthBucketFunction,
+    "22018": InvalidCharacterValueForCast,
+    "22007": InvalidDatetimeFormat,
+    "22019": InvalidEscapeCharacter,
+    "2200D": InvalidEscapeOctet,
+    "22025": InvalidEscapeSequence,
+    "22P06": NonstandardUseOfEscapeCharacter,
+    "22010": InvalidIndicatorParameterValue,
+    "22023": InvalidParameterValue,
+    "22013": InvalidPrecedingOrFollowingSize,
+    "2201B": InvalidRegularExpression,
+    "2201W": InvalidRowCountInLimitClause,
+    "2201X": InvalidRowCountInResultOffsetClause,
+    "2202H": InvalidTablesampleArgument,
+    "2202G": InvalidTablesampleRepeat,
+    "22009": InvalidTimeZoneDisplacementValue,
+    "2200C": InvalidUseOfEscapeCharacter,
+    "2200G": MostSpecificTypeMismatch,
+    "22004": NullValueNotAllowed,
+    "22002": NullValueNoIndicatorParameter,
+    "22003": NumericValueOutOfRange,
+    "2200H": SequenceGeneratorLimitExceeded,
+    "22026": StringDataLengthMismatch,
+    "22001": StringDataRightTruncation,
+    "22011": SubstringError,
+    "22027": TrimError,
+    "22024": UnterminatedCString,
+    "2200F": ZeroLengthCharacterString,
+    "22P01": FloatingPointException,
+    "22P02": InvalidTextRepresentation,
+    "22P03": InvalidBinaryRepresentation,
+    "22P04": BadCopyFileFormat,
+    "22P05": UntranslatableCharacter,
+    "2200L": NotAnXmlDocument,
+    "2200M": InvalidXmlDocument,
+    "2200N": InvalidXmlContent,
+    "2200S": InvalidXmlComment,
+    "2200T": InvalidXmlProcessingInstruction,
+    "22030": DuplicateJsonObjectKeyValue,
+    "22031": InvalidArgumentForSQLJsonDatetimeFunction,
+    "22032": InvalidJsonText,
+    "22033": InvalidSQLJsonSubscript,
+    "22034": MoreThanOneSQLJsonItem,
+    "22035": NoSQLJsonItem,
+    "22036": NonNumericSQLJsonItem,
+    "22037": NonUniqueKeysInAJsonObject,
+    "22038": SingletonSQLJsonItemRequired,
+    "22039": SQLJsonArrayNotFound,
+    "2203A": SQLJsonMemberNotFound,
+    "2203B": SQLJsonNumberNotFound,
+    "2203C": SQLJsonObjectNotFound,
+    "2203D": TooManyJsonArrayElements,
+    "2203E": TooManyJsonObjectMembers,
+    "2203F": SQLJsonScalarRequired,
+    "2203G": SQLJsonItemCannotBeCastToTargetType,
+    "23000": IntegrityConstraintViolation,
+    "23001": RestrictViolation,
+    "23502": NotNullViolation,
+    "23503": ForeignKeyViolation,
+    "23505": UniqueViolation,
+    "23514": CheckViolation,
+    "23P01": ExclusionViolation,
+    "24000": InvalidCursorState,
+    "25000": InvalidTransactionState,
+    "25001": ActiveSQLTransaction,
+    "25002": BranchTransactionAlreadyActive,
+    "25008": HeldCursorRequiresSameIsolationLevel,
+    "25003": InappropriateAccessModeForBranchTransaction,
+    "25004": InappropriateIsolationLevelForBranchTransaction,
+    "25005": NoActiveSQLTransactionForBranchTransaction,
+    "25006": ReadOnlySQLTransaction,
+    "25007": SchemaAndDataStatementMixingNotSupported,
+    "25P01": NoActiveSQLTransaction,
+    "25P02": InFailedSQLTransaction,
+    "25P03": IdleInTransactionSessionTimeout,
+    "25P04": TransactionTimeout,
+    "26000": InvalidSQLStatementName,
+    "27000": TriggeredDataChangeViolation,
+    "28000": InvalidAuthorizationSpecification,
+    "28P01": InvalidPassword,
+    "2B000": DependentPrivilegeDescriptorsStillExist,
+    "2BP01": DependentObjectsStillExist,
+    "2D000": InvalidTransactionTermination,
+    "2F000": SQLRoutineException,
+    "2F005": FunctionExecutedNoReturnStatement,
+    "2F002": SREModifyingSQLDataNotPermitted,
+    "2F003": SREProhibitedSQLStatementAttempted,
+    "2F004": SREReadingSQLDataNotPermitted,
+    "34000": InvalidCursorName,
+    "38000": ExternalRoutineException,
+    "38001": ContainingSQLNotPermitted,
+    "38002": EREModifyingSQLDataNotPermitted,
+    "38003": EREProhibitedSQLStatementAttempted,
+    "38004": EREReadingSQLDataNotPermitted,
+    "39000": ExternalRoutineInvocationException,
+    "39001": InvalidSqlstateReturned,
+    "39004": ERIENullValueNotAllowed,
+    "39P01": TriggerProtocolViolated,
+    "39P02": SrfProtocolViolated,
+    "39P03": EventTriggerProtocolViolated,
+    "3B000": SavepointException,
+    "3B001": InvalidSavepointSpecification,
+    "3D000": InvalidCatalogName,
+    "3F000": InvalidSchemaName,
+    "40000": TransactionRollback,
+    "40002": TransactionIntegrityConstraintViolation,
+    "40001": SerializationFailure,
+    "40003": StatementCompletionUnknown,
+    "40P01": DeadlockDetected,
+    "42000": SyntaxErrorOrAccessRuleViolation,
+    "42601": SyntaxError,
+    "42501": InsufficientPrivilege,
+    "42846": CannotCoerce,
+    "42803": GroupingError,
+    "42P20": WindowingError,
+    "42P19": InvalidRecursion,
+    "42830": InvalidForeignKey,
+    "42602": InvalidName,
+    "42622": NameTooLong,
+    "42939": ReservedName,
+    "42804": DatatypeMismatch,
+    "42P18": IndeterminateDatatype,
+    "42P21": CollationMismatch,
+    "42P22": IndeterminateCollation,
+    "42809": WrongObjectType,
+    "428C9": GeneratedAlways,
+    "42703": UndefinedColumn,
+    "42883": UndefinedFunction,
+    "42P01": UndefinedTable,
+    "42P02": UndefinedParameter,
+    "42704": UndefinedObject,
+    "42701": DuplicateColumn,
+    "42P03": DuplicateCursor,
+    "42P04": DuplicateDatabase,
+    "42723": DuplicateFunction,
+    "42P05": DuplicatePreparedStatement,
+    "42P06": DuplicateSchema,
+    "42P07": DuplicateTable,
+    "42712": DuplicateAlias,
+    "42710": DuplicateObject,
+    "42702": AmbiguousColumn,
+    "42725": AmbiguousFunction,
+    "42P08": AmbiguousParameter,
+    "42P09": AmbiguousAlias,
+    "42P10": InvalidColumnReference,
+    "42611": InvalidColumnDefinition,
+    "42P11": InvalidCursorDefinition,
+    "42P12": InvalidDatabaseDefinition,
+    "42P13": InvalidFunctionDefinition,
+    "42P14": InvalidPreparedStatementDefinition,
+    "42P15": InvalidSchemaDefinition,
+    "42P16": InvalidTableDefinition,
+    "42P17": InvalidObjectDefinition,
+    "44000": WithCheckOptionViolation,
+    "53000": InsufficientResources,
+    "53100": DiskFull,
+    "53200": OutOfMemory,
+    "53300": TooManyConnections,
+    "53400": ConfigurationLimitExceeded,
+    "54000": ProgramLimitExceeded,
+    "54001": StatementTooComplex,
+    "54011": TooManyColumns,
+    "54023": TooManyArguments,
+    "55000": ObjectNotInPrerequisiteState,
+    "55006": ObjectInUse,
+    "55P02": CantChangeRuntimeParam,
+    "55P03": LockNotAvailable,
+    "55P04": UnsafeNewEnumValueUsage,
+    "57000": OperatorIntervention,
+    "57014": QueryCanceled,
+    "57P01": AdminShutdown,
+    "57P02": CrashShutdown,
+    "57P03": CannotConnectNow,
+    "57P04": DatabaseDropped,
+    "57P05": IdleSessionTimeout,
+    "58000": SystemError,
+    "58030": IoError,
+    "58P01": UndefinedFile,
+    "58P02": DuplicateFile,
+    "58P03": FileNameTooLong,
+    "F0000": ConfigFileError,
+    "F0001": LockFileExists,
+    "HV000": FDWError,
+    "HV005": FDWColumnNameNotFound,
+    "HV002": FDWDynamicParameterValueNeeded,
+    "HV010": FDWFunctionSequenceError,
+    "HV021": FDWInconsistentDescriptorInformation,
+    "HV024": FDWInvalidAttributeValue,
+    "HV007": FDWInvalidColumnName,
+    "HV008": FDWInvalidColumnNumber,
+    "HV004": FDWInvalidDataType,
+    "HV006": FDWInvalidDataTypeDescriptors,
+    "HV091": FDWInvalidDescriptorFieldIdentifier,
+    "HV00B": FDWInvalidHandle,
+    "HV00C": FDWInvalidOptionIndex,
+    "HV00D": FDWInvalidOptionName,
+    "HV090": FDWInvalidStringLengthOrBufferLength,
+    "HV00A": FDWInvalidStringFormat,
+    "HV009": FDWInvalidUseOfNullPointer,
+    "HV014": FDWTooManyHandles,
+    "HV001": FDWOutOfMemory,
+    "HV00P": FDWNoSchemas,
+    "HV00J": FDWOptionNameNotFound,
+    "HV00K": FDWReplyHandle,
+    "HV00Q": FDWSchemaNotFound,
+    "HV00R": FDWTableNotFound,
+    "HV00L": FDWUnableToCreateExecution,
+    "HV00M": FDWUnableToCreateReply,
+    "HV00N": FDWUnableToEstablishConnection,
+    "P0000": PlpgsqlError,
+    "P0001": RaiseException,
+    "P0002": NoDataFound,
+    "P0003": TooManyRows,
+    "P0004": AssertFailure,
+    "XX000": InternalError,
+    "XX001": DataCorrupted,
+    "XX002": IndexCorrupted,
+}
+
+
+__all__ = [
+    "InvalidCursorName",
+    "UndefinedParameter",
+    "UndefinedColumn",
+    "NotAnXmlDocument",
+    "FDWOutOfMemory",
+    "InvalidRoleSpecification",
+    "InvalidArgumentForNthValueFunction",
+    "SQLJsonObjectNotFound",
+    "FDWSchemaNotFound",
+    "InvalidParameterValue",
+    "InvalidTableDefinition",
+    "AssertFailure",
+    "FDWInvalidOptionName",
+    "InvalidEscapeOctet",
+    "ReadOnlySQLTransaction",
+    "ExternalRoutineInvocationException",
+    "CrashShutdown",
+    "FDWInvalidOptionIndex",
+    "NotNullViolation",
+    "ConfigFileError",
+    "InvalidSQLJsonSubscript",
+    "InvalidForeignKey",
+    "InsufficientResources",
+    "ObjectNotInPrerequisiteState",
+    "InvalidRowCountInLimitClause",
+    "IntervalFieldOverflow",
+    "CollationMismatch",
+    "InvalidArgumentForNtileFunction",
+    "InvalidCharacterValueForCast",
+    "NonUniqueKeysInAJsonObject",
+    "DependentPrivilegeDescriptorsStillExist",
+    "InFailedSQLTransaction",
+    "GroupingError",
+    "TransactionTimeout",
+    "CaseNotFound",
+    "ConnectionException",
+    "DuplicateJsonObjectKeyValue",
+    "InvalidSchemaDefinition",
+    "FDWUnableToCreateReply",
+    "UndefinedTable",
+    "SequenceGeneratorLimitExceeded",
+    "InvalidJsonText",
+    "IdleSessionTimeout",
+    "NullValueNotAllowed",
+    "BranchTransactionAlreadyActive",
+    "InvalidGrantOperation",
+    "NullValueNoIndicatorParameter",
+    "ProtocolViolation",
+    "FDWInvalidDataTypeDescriptors",
+    "TriggeredDataChangeViolation",
+    "ExternalRoutineException",
+    "InvalidSqlstateReturned",
+    "PlpgsqlError",
+    "InvalidXmlContent",
+    "TriggeredActionException",
+    "SQLClientUnableToEstablishSQLConnection",
+    "FDWTableNotFound",
+    "NumericValueOutOfRange",
+    "RestrictViolation",
+    "AmbiguousParameter",
+    "StatementTooComplex",
+    "UnsafeNewEnumValueUsage",
+    "NonNumericSQLJsonItem",
+    "InvalidIndicatorParameterValue",
+    "ExclusionViolation",
+    "OperatorIntervention",
+    "QueryCanceled",
+    "Warning",
+    "InvalidArgumentForSQLJsonDatetimeFunction",
+    "ForeignKeyViolation",
+    "StringDataLengthMismatch",
+    "SQLRoutineException",
+    "TooManyConnections",
+    "TooManyJsonObjectMembers",
+    "NoData",
+    "UntranslatableCharacter",
+    "FDWUnableToEstablishConnection",
+    "LockFileExists",
+    "SREReadingSQLDataNotPermitted",
+    "IndeterminateDatatype",
+    "CheckViolation",
+    "InvalidDatabaseDefinition",
+    "NoActiveSQLTransactionForBranchTransaction",
+    "SQLServerRejectedEstablishmentOfSQLConnection",
+    "DuplicateFile",
+    "FDWInvalidColumnNumber",
+    "TransactionRollback",
+    "MoreThanOneSQLJsonItem",
+    "WithCheckOptionViolation",
+    "FDWNoSchemas",
+    "GeneratedAlways",
+    "CannotConnectNow",
+    "CardinalityViolation",
+    "InvalidAuthorizationSpecification",
+    "SQLJsonNumberNotFound",
+    "SQLJsonMemberNotFound",
+    "InvalidUseOfEscapeCharacter",
+    "UnterminatedCString",
+    "TrimError",
+    "SrfProtocolViolated",
+    "DiskFull",
+    "TooManyColumns",
+    "InvalidObjectDefinition",
+    "InvalidArgumentForLogarithm",
+    "TooManyJsonArrayElements",
+    "OutOfMemory",
+    "EREProhibitedSQLStatementAttempted",
+    "FDWInvalidStringFormat",
+    "StackedDiagnosticsAccessedWithoutActiveHandler",
+    "SchemaAndDataStatementMixingNotSupported",
+    "InternalError",
+    "InvalidEscapeCharacter",
+    "FDWError",
+    "ImplicitZeroBitPaddingWarning",
+    "DivisionByZero",
+    "InvalidTablesampleArgument",
+    "DeadlockDetected",
+    "CantChangeRuntimeParam",
+    "UndefinedObject",
+    "UniqueViolation",
+    "InvalidCursorDefinition",
+    "ConnectionFailure",
+    "UndefinedFunction",
+    "FDWFunctionSequenceError",
+    "ErrorInAssignment",
+    "SuccessfulCompletion",
+    "StringDataRightTruncation",
+    "FDWTooManyHandles",
+    "FDWInvalidDataType",
+    "ActiveSQLTransaction",
+    "InvalidTextRepresentation",
+    "InvalidSQLStatementName",
+    "PrivilegeNotGrantedWarning",
+    "SREModifyingSQLDataNotPermitted",
+    "IndeterminateCollation",
+    "SystemError",
+    "NullValueEliminatedInSetFunctionWarning",
+    "DependentObjectsStillExist",
+    "InvalidSchemaName",
+    "DuplicateColumn",
+    "FunctionExecutedNoReturnStatement",
+    "InvalidColumnDefinition",
+    "DynamicResultSetsReturnedWarning",
+    "IdleInTransactionSessionTimeout",
+    "StatementCompletionUnknown",
+    "CannotCoerce",
+    "InvalidTransactionState",
+    "DuplicateTable",
+    "BadCopyFileFormat",
+    "ZeroLengthCharacterString",
+    "SyntaxErrorOrAccessRuleViolation",
+    "SingletonSQLJsonItemRequired",
+    "IndexCorrupted",
+    "FDWInvalidColumnName",
+    "DataCorrupted",
+    "ERIENullValueNotAllowed",
+    "ArraySubscriptError",
+    "FDWReplyHandle",
+    "DiagnosticsException",
+    "InvalidTablesampleRepeat",
+    "SQLJsonItemCannotBeCastToTargetType",
+    "FDWInvalidHandle",
+    "InvalidPassword",
+    "InvalidEscapeSequence",
+    "EscapeCharacterConflict",
+    "InvalidSavepointSpecification",
+    "FDWInvalidAttributeValue",
+    "ContainingSQLNotPermitted",
+    "LocatorException",
+    "DatatypeMismatch",
+    "InvalidCursorState",
+    "InvalidName",
+    "IndicatorOverflow",
+    "ReservedName",
+    "DatetimeFieldOverflow",
+    "FDWInconsistentDescriptorInformation",
+    "FloatingPointException",
+    "AmbiguousAlias",
+    "InvalidRecursion",
+    "WrongObjectType",
+    "UndefinedFile",
+    "LockNotAvailable",
+    "InvalidRowCountInResultOffsetClause",
+    "ObjectInUse",
+    "DeprecatedFeatureWarning",
+    "FDWDynamicParameterValueNeeded",
+    "DuplicateFunction",
+    "InvalidXmlDocument",
+    "StringDataRightTruncationWarning",
+    "DuplicatePreparedStatement",
+    "InvalidGrantor",
+    "EventTriggerProtocolViolated",
+    "FDWInvalidUseOfNullPointer",
+    "FDWUnableToCreateExecution",
+    "ConnectionDoesNotExist",
+    "InvalidCatalogName",
+    "InvalidArgumentForXquery",
+    "FDWColumnNameNotFound",
+    "TransactionIntegrityConstraintViolation",
+    "InvalidPreparedStatementDefinition",
+    "FDWInvalidDescriptorFieldIdentifier",
+    "FDWOptionNameNotFound",
+    "InvalidArgumentForPowerFunction",
+    "FDWInvalidStringLengthOrBufferLength",
+    "SREProhibitedSQLStatementAttempted",
+    "NoDataFound",
+    "DuplicateDatabase",
+    "FeatureNotSupported",
+    "IntegrityConstraintViolation",
+    "AmbiguousColumn",
+    "PrivilegeNotRevokedWarning",
+    "FileNameTooLong",
+    "InvalidArgumentForWidthBucketFunction",
+    "HeldCursorRequiresSameIsolationLevel",
+    "NoSQLJsonItem",
+    "IoError",
+    "SavepointException",
+    "NoActiveSQLTransaction",
+    "InvalidFunctionDefinition",
+    "AdminShutdown",
+    "DatabaseDropped",
+    "InvalidRegularExpression",
+    "WindowingError",
+    "InvalidColumnReference",
+    "InvalidBinaryRepresentation",
+    "SQLJsonScalarRequired",
+    "ConfigurationLimitExceeded",
+    "SyntaxError",
+    "SerializationFailure",
+    "ProgramLimitExceeded",
+    "DuplicateSchema",
+    "SQLStatementNotYetComplete",
+    "LibpqError",
+    "DataException",
+    "SubstringError",
+    "InvalidLocatorSpecification",
+    "InappropriateAccessModeForBranchTransaction",
+    "EREModifyingSQLDataNotPermitted",
+    "InsufficientPrivilege",
+    "NoAdditionalDynamicResultSetsReturned",
+    "SQLJsonArrayNotFound",
+    "NameTooLong",
+    "InvalidTimeZoneDisplacementValue",
+    "InappropriateIsolationLevelForBranchTransaction",
+    "RaiseException",
+    "EREReadingSQLDataNotPermitted",
+    "TriggerProtocolViolated",
+    "NonstandardUseOfEscapeCharacter",
+    "InvalidTransactionInitiation",
+    "DuplicateAlias",
+    "TransactionResolutionUnknown",
+    "TooManyRows",
+    "InvalidXmlComment",
+    "MostSpecificTypeMismatch",
+    "DuplicateObject",
+    "DuplicateCursor",
+    "AmbiguousFunction",
+    "TooManyArguments",
+    "InvalidXmlProcessingInstruction",
+    "InvalidTransactionTermination",
+    "InvalidDatetimeFormat",
+    "InvalidPrecedingOrFollowingSize",
+    "CharacterNotInRepertoire",
+    "SQLSTATE_TO_EXCEPTION",
+]
diff --git a/src/test/pytest/libpq/errors.py b/src/test/pytest/libpq/errors.py
new file mode 100644
index 00000000000..764a96c2478
--- /dev/null
+++ b/src/test/pytest/libpq/errors.py
@@ -0,0 +1,39 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+"""
+PostgreSQL error types mapped from SQLSTATE codes.
+
+This module provides LibpqError and its subclasses for handling PostgreSQL
+errors based on SQLSTATE codes. The exception classes in _generated_errors.py
+are auto-generated from src/backend/utils/errcodes.txt.
+
+To regenerate: src/tools/generate_pytest_libpq_errors.py
+"""
+
+from typing import Optional
+
+from ._error_base import LibpqError, LibpqWarning
+from ._generated_errors import (
+    SQLSTATE_TO_EXCEPTION,
+)
+from ._generated_errors import *  # noqa: F403
+
+
+def get_exception_class(sqlstate: Optional[str]) -> type:
+    """Get the appropriate exception class for a SQLSTATE code."""
+    if sqlstate in SQLSTATE_TO_EXCEPTION:
+        return SQLSTATE_TO_EXCEPTION[sqlstate]
+    return LibpqError
+
+
+def make_error(message: str, *, sqlstate: Optional[str] = None, **kwargs) -> LibpqError:
+    """Create an appropriate LibpqError subclass based on the SQLSTATE code."""
+    exc_class = get_exception_class(sqlstate)
+    return exc_class(message, sqlstate=sqlstate, **kwargs)
+
+
+__all__ = [
+    "LibpqError",
+    "LibpqWarning",
+    "make_error",
+]
diff --git a/src/test/pytest/meson.build b/src/test/pytest/meson.build
index abd128dfa24..20ca4416ebc 100644
--- a/src/test/pytest/meson.build
+++ b/src/test/pytest/meson.build
@@ -10,7 +10,9 @@ tests += {
   'bd': meson.current_build_dir(),
   'pytest': {
     'tests': [
-      'pyt/test_something.py',
+      'pyt/test_errors.py',
+      'pyt/test_libpq.py',
+      'pyt/test_query_helpers.py',
     ],
   },
 }
diff --git a/src/test/pytest/pypg/__init__.py b/src/test/pytest/pypg/__init__.py
new file mode 100644
index 00000000000..5dae49b6406
--- /dev/null
+++ b/src/test/pytest/pypg/__init__.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+from ._env import has_test_extra, require_test_extra
+from ._win32 import current_windows_user
diff --git a/src/test/pytest/pypg/_env.py b/src/test/pytest/pypg/_env.py
new file mode 100644
index 00000000000..154c986d73e
--- /dev/null
+++ b/src/test/pytest/pypg/_env.py
@@ -0,0 +1,54 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+import logging
+import os
+
+import pytest
+
+logger = logging.getLogger(__name__)
+
+
+def has_test_extra(key: str) -> bool:
+    """
+    Returns True if the PG_TEST_EXTRA environment variable contains the given
+    key.
+    """
+    extra = os.getenv("PG_TEST_EXTRA", "")
+    return key in extra.split()
+
+
+def require_test_extra(*keys: str) -> bool:
+    """
+    A convenience annotation which will skip tests if all of the required keys
+    are not present in PG_TEST_EXTRA.
+
+    To skip a particular test function or class:
+
+        @pg.require_test_extra("ldap")
+        def test_some_ldap_feature():
+            ...
+
+    To skip an entire module:
+
+        pytestmark = pg.require_test_extra("ssl", "kerberos")
+    """
+    return pytest.mark.skipif(
+        not all([has_test_extra(k) for k in keys]),
+        reason="requires {} to be set in PG_TEST_EXTRA".format(", ".join(keys)),
+    )
+
+
+def test_timeout_default() -> int:
+    """
+    Returns the value of the PG_TEST_TIMEOUT_DEFAULT environment variable, in
+    seconds, or 180 if one was not provided.
+    """
+    default = os.getenv("PG_TEST_TIMEOUT_DEFAULT", "")
+    if not default:
+        return 180
+
+    try:
+        return int(default)
+    except ValueError as v:
+        logger.warning("PG_TEST_TIMEOUT_DEFAULT could not be parsed: " + str(v))
+        return 180
diff --git a/src/test/pytest/pypg/_win32.py b/src/test/pytest/pypg/_win32.py
new file mode 100644
index 00000000000..3fd67b10191
--- /dev/null
+++ b/src/test/pytest/pypg/_win32.py
@@ -0,0 +1,145 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+import ctypes
+import platform
+
+
+def current_windows_user():
+    """
+    A port of pg_regress.c's current_windows_user() helper. Returns
+    (accountname, domainname).
+
+    XXX This is dead code now, but I'm keeping it as a motivating example of
+    Win32 interaction, and someone may find it useful in the future when writing
+    SSPI tests?
+    """
+    try:
+        advapi32 = ctypes.windll.advapi32
+        kernel32 = ctypes.windll.kernel32
+    except AttributeError:
+        raise RuntimeError(
+            f"current_windows_user() is not supported on {platform.system()}"
+        )
+
+    def raise_winerror_when_false(result, func, arguments):
+        """
+        A ctypes errcheck handler that raises WinError (which will contain the
+        result of GetLastError()) when the function's return value is false.
+        """
+        if not result:
+            raise ctypes.WinError()
+
+    #
+    # Function Prototypes
+    #
+
+    from ctypes import wintypes
+
+    # GetCurrentProcess
+    kernel32.GetCurrentProcess.restype = wintypes.HANDLE
+    kernel32.GetCurrentProcess.argtypes = []
+
+    # OpenProcessToken
+    TOKEN_READ = 0x00020008
+
+    advapi32.OpenProcessToken.restype = wintypes.BOOL
+    advapi32.OpenProcessToken.argtypes = [
+        wintypes.HANDLE,
+        wintypes.DWORD,
+        wintypes.PHANDLE,
+    ]
+    advapi32.OpenProcessToken.errcheck = raise_winerror_when_false
+
+    # GetTokenInformation
+    PSID = wintypes.LPVOID  # we don't need the internals
+    TOKEN_INFORMATION_CLASS = wintypes.INT
+    TokenUser = 1
+
+    class SID_AND_ATTRIBUTES(ctypes.Structure):
+        _fields_ = [
+            ("Sid", PSID),
+            ("Attributes", wintypes.DWORD),
+        ]
+
+    class TOKEN_USER(ctypes.Structure):
+        _fields_ = [
+            ("User", SID_AND_ATTRIBUTES),
+        ]
+
+    advapi32.GetTokenInformation.restype = wintypes.BOOL
+    advapi32.GetTokenInformation.argtypes = [
+        wintypes.HANDLE,
+        TOKEN_INFORMATION_CLASS,
+        wintypes.LPVOID,
+        wintypes.DWORD,
+        wintypes.PDWORD,
+    ]
+    advapi32.GetTokenInformation.errcheck = raise_winerror_when_false
+
+    # LookupAccountSid
+    SID_NAME_USE = wintypes.INT
+    PSID_NAME_USE = ctypes.POINTER(SID_NAME_USE)
+
+    advapi32.LookupAccountSidW.restype = wintypes.BOOL
+    advapi32.LookupAccountSidW.argtypes = [
+        wintypes.LPCWSTR,
+        PSID,
+        wintypes.LPWSTR,
+        wintypes.LPDWORD,
+        wintypes.LPWSTR,
+        wintypes.LPDWORD,
+        PSID_NAME_USE,
+    ]
+    advapi32.LookupAccountSidW.errcheck = raise_winerror_when_false
+
+    #
+    # Implementation (see pg_SSPI_recv_auth())
+    #
+
+    # Get the current process token...
+    token = wintypes.HANDLE()
+    proc = kernel32.GetCurrentProcess()
+    advapi32.OpenProcessToken(proc, TOKEN_READ, token)
+
+    # ...then read the TOKEN_USER struct for that token...
+    info = TOKEN_USER()
+    infolen = wintypes.DWORD()
+
+    try:
+        # (GetTokenInformation creates a buffer bigger than TOKEN_USER, so we
+        # have to query the correct length first.)
+        advapi32.GetTokenInformation(token, TokenUser, None, 0, ctypes.byref(infolen))
+        assert False, "GetTokenInformation succeeded unexpectedly"
+
+    except OSError as err:
+        assert err.winerror == 122  # insufficient buffer
+
+        ctypes.resize(info, infolen.value)
+        advapi32.GetTokenInformation(
+            token,
+            TokenUser,
+            ctypes.byref(info),
+            ctypes.sizeof(info),
+            ctypes.byref(infolen),
+        )
+
+    # ...then pull the account and domain names out of the user SID.
+    MAXPGPATH = 1024
+
+    account = ctypes.create_unicode_buffer(MAXPGPATH)
+    domain = ctypes.create_unicode_buffer(MAXPGPATH)
+    accountlen = wintypes.DWORD(ctypes.sizeof(account))
+    domainlen = wintypes.DWORD(ctypes.sizeof(domain))
+    use = SID_NAME_USE()
+
+    advapi32.LookupAccountSidW(
+        None,
+        info.User.Sid,
+        account,
+        ctypes.byref(accountlen),
+        domain,
+        ctypes.byref(domainlen),
+        ctypes.byref(use),
+    )
+
+    return (account.value, domain.value)
diff --git a/src/test/pytest/pypg/fixtures.py b/src/test/pytest/pypg/fixtures.py
new file mode 100644
index 00000000000..9caa5b22b25
--- /dev/null
+++ b/src/test/pytest/pypg/fixtures.py
@@ -0,0 +1,191 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+import os
+import contextlib
+import pathlib
+import secrets
+import time
+
+import pytest
+
+from ._env import test_timeout_default
+from .util import capture
+from .server import PostgresServer
+
+from libpq import load_libpq_handle, connect as libpq_connect
+
+
+@pytest.fixture
+def remaining_timeout():
+    """
+    This fixture provides a function that returns how much of the
+    PG_TEST_TIMEOUT_DEFAULT remains for the current test, in fractional seconds.
+    This value is never less than zero.
+
+    This fixture is per-test, so the deadline is also reset on a per-test basis.
+    """
+    now = time.monotonic()
+    deadline = now + test_timeout_default()
+
+    return lambda: max(deadline - time.monotonic(), 0)
+
+
+@pytest.fixture(scope="session")
+def libpq_handle(libdir, bindir):
+    """
+    Loads a ctypes handle for libpq. Some common function prototypes are
+    initialized for general use.
+    """
+    try:
+        return load_libpq_handle(libdir, bindir)
+    except OSError as e:
+        if "wrong ELF class" in str(e):
+            # This happens in CI when trying to lead a 32-bit libpq library
+            # with a 64-bit Python
+            pytest.skip("libpq architecture does not match Python interpreter")
+        raise
+
+
+@pytest.fixture
+def connect(libpq_handle, remaining_timeout):
+    """
+    Returns a function to connect to PostgreSQL via libpq.
+
+    The returned function accepts connection options as keyword arguments
+    (host, port, dbname, etc.) and returns a PGconn object. Connections
+    are automatically cleaned up at the end of the test.
+
+    Example:
+        conn = connect(host='localhost', port=5432, dbname='postgres')
+        result = conn.sql("SELECT 1")
+    """
+    with contextlib.ExitStack() as stack:
+
+        def _connect(**opts):
+            return libpq_connect(libpq_handle, stack, remaining_timeout, **opts)
+
+        yield _connect
+
+
+@pytest.fixture(scope="session")
+def pg_config():
+    """
+    Returns the path to pg_config. Uses PG_CONFIG environment variable if set,
+    otherwise uses 'pg_config' from PATH.
+    """
+    return os.environ.get("PG_CONFIG", "pg_config")
+
+
+@pytest.fixture(scope="session")
+def bindir(pg_config):
+    """
+    Returns the PostgreSQL bin directory using pg_config --bindir.
+    """
+    return capture(pg_config, "--bindir")
+
+
+@pytest.fixture(scope="session")
+def libdir(pg_config):
+    """
+    Returns the PostgreSQL lib directory using pg_config --libdir.
+    """
+    return capture(pg_config, "--libdir")
+
+
+@pytest.fixture(scope="session")
+def tmp_check(tmp_path_factory) -> pathlib.Path:
+    """
+    Returns the tmp_check directory that should be used for the tests. If
+    TESTDATADIR is provided, that will be used; otherwise a new temporary
+    directory is created in the pytest temp root.
+    """
+    d = os.getenv("TESTDATADIR")
+    if d:
+        d = pathlib.Path(d)
+    else:
+        d = tmp_path_factory.mktemp("tmp_check")
+
+    return tmp_path_factory.mktemp("check_tmp")
+
+
+@pytest.fixture(scope="session")
+def datadir(tmp_check):
+    """
+    Returns the data directory to use for the pg fixture.
+    """
+
+    return tmp_check / "pgdata"
+
+
+@pytest.fixture(scope="session")
+def sockdir(tmp_path_factory):
+    """
+    Returns the directory name to use as the server's unix_socket_directories
+    setting. Local client connections use this as the PGHOST.
+
+    At the moment, this is always put under the pytest temp root.
+    """
+    return tmp_path_factory.mktemp("sockfiles")
+
+
+@pytest.fixture(scope="session")
+def winpassword():
+    """The per-session SCRAM password for the server admin on Windows."""
+    return secrets.token_urlsafe(16)
+
+
+@pytest.fixture(scope="session")
+def pg_server_global(bindir, datadir, sockdir, winpassword, libpq_handle):
+    """
+    Starts a running Postgres server listening on localhost. The HBA initially
+    allows only local UNIX connections from the same user.
+
+    Returns a PostgresServer instance with methods for server management, configuration,
+    and creating test databases/users.
+    """
+    server = PostgresServer(bindir, datadir, sockdir, winpassword, libpq_handle)
+
+    yield server
+
+    # Cleanup any test resources
+    server.cleanup()
+
+    # Stop the server
+    server.stop()
+
+
+@pytest.fixture(scope="module")
+def pg_server_module(pg_server_global):
+    """
+    Module-scoped server context. Which can be useful so that certain settings
+    can be overriden at the module level through autouse fixtures. An example
+    of this is in the SSL tests.
+    """
+    with pg_server_global.subcontext() as s:
+        yield s
+
+
+@pytest.fixture
+def pg(pg_server_module, remaining_timeout):
+    """
+    Per-test server context. Use this fixture to make changes to the server
+    which will be rolled back at the end of the test (e.g., creating test
+    users/databases).
+    """
+    pg_server_module.set_timeout(remaining_timeout)
+    with pg_server_module.subcontext() as s:
+        yield s
+
+
+@pytest.fixture
+def conn(pg):
+    """
+    Returns a connected PGconn instance to the test PostgreSQL server.
+    The connection is automatically cleaned up at the end of the test.
+
+    Example:
+        def test_something(conn):
+            result = conn.sql("SELECT 1")
+            assert result == 1
+    """
+    return pg.connect()
diff --git a/src/test/pytest/pypg/server.py b/src/test/pytest/pypg/server.py
new file mode 100644
index 00000000000..dd2aa4fc434
--- /dev/null
+++ b/src/test/pytest/pypg/server.py
@@ -0,0 +1,391 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+import contextlib
+import glob
+import os
+import pathlib
+import platform
+import socket
+import subprocess
+import tempfile
+import time
+from collections import namedtuple
+from typing import Callable, Optional
+
+from .util import run
+from libpq import PGconn
+
+
+class FileBackup(contextlib.AbstractContextManager):
+    """
+    A context manager which backs up a file's contents, restoring them on exit.
+    """
+
+    def __init__(self, file: pathlib.Path):
+        super().__init__()
+
+        self._file = file
+
+    def __enter__(self):
+        import shutil
+
+        with tempfile.NamedTemporaryFile(
+            prefix=self._file.name, dir=self._file.parent, delete=False
+        ) as f:
+            self._backup = pathlib.Path(f.name)
+
+        shutil.copyfile(self._file, self._backup)
+
+        return self
+
+    def __exit__(self, *exc):
+        import shutil
+
+        # Swap the backup and the original file, so that the modified contents
+        # can still be inspected in case of failure.
+        tmp = self._backup.parent / (self._backup.name + ".tmp")
+
+        shutil.copyfile(self._file, tmp)
+        shutil.copyfile(self._backup, self._file)
+        shutil.move(tmp, self._backup)
+
+
+class HBA(FileBackup):
+    """
+    Backs up a server's HBA configuration and provides means for temporarily
+    editing it.
+    """
+
+    def __init__(self, datadir: pathlib.Path):
+        super().__init__(datadir / "pg_hba.conf")
+
+    def prepend(self, *lines):
+        """
+        Temporarily prepends lines to the server's pg_hba.conf.
+
+        As sugar for aligning HBA columns in the tests, each line can be either
+        a string or a list of strings. List elements will be joined by single
+        spaces before they are written to file.
+        """
+        with open(self._file, "r") as f:
+            prior_data = f.read()
+
+        with open(self._file, "w") as f:
+            for line in lines:
+                if isinstance(line, list):
+                    print(*line, file=f)
+                else:
+                    print(line, file=f)
+
+            f.write(prior_data)
+
+
+class Config(FileBackup):
+    """
+    Backs up a server's postgresql.conf and provides means for temporarily
+    editing it.
+    """
+
+    def __init__(self, datadir: pathlib.Path):
+        super().__init__(datadir / "postgresql.conf")
+
+    def set(self, **gucs):
+        """
+        Temporarily appends GUC settings to the server's postgresql.conf.
+        """
+
+        with open(self._file, "a") as f:
+            print(file=f)
+
+            for n, v in gucs.items():
+                v = str(v)
+
+                # TODO: proper quoting
+                v = v.replace("\\", "\\\\")
+                v = v.replace("'", "\\'")
+                v = "'{}'".format(v)
+
+                print(n, "=", v, file=f)
+
+
+Backup = namedtuple("Backup", "conf, hba")
+
+
+class PostgresServer:
+    """
+    Represents a running PostgreSQL server instance with management utilities.
+    Provides methods for configuration, user/database creation, and server control.
+    """
+
+    def __init__(self, bindir, datadir, sockdir, winpassword, libpq_handle):
+        """
+        Initialize and start a PostgreSQL server instance.
+        """
+        self.datadir = datadir
+        self.sockdir = sockdir
+        self.libpq_handle = libpq_handle
+        self._remaining_timeout_fn: Optional[Callable[[], float]] = None
+        self._bindir = bindir
+        self._winpassword = winpassword
+        self._pg_ctl = os.path.join(bindir, "pg_ctl")
+        self._log = os.path.join(datadir, "postgresql.log")
+
+        initdb = os.path.join(bindir, "initdb")
+        pg_ctl = self._pg_ctl
+
+        # Lock down the HBA by default; tests can open it back up later.
+        if platform.system() == "Windows":
+            # On Windows, for admin connections, use SCRAM with a generated password
+            # over local sockets. This requires additional work during initdb.
+            method = "scram-sha-256"
+
+            # NamedTemporaryFile doesn't work very nicely on Windows until Python
+            # 3.12, which introduces NamedTemporaryFile(delete_on_close=False).
+            # Until then, specify delete=False and manually unlink after use.
+            with tempfile.NamedTemporaryFile("w", delete=False) as pwfile:
+                pwfile.write(winpassword)
+
+            run(initdb, "--auth=scram-sha-256", "--pwfile", pwfile.name, datadir)
+            os.unlink(pwfile.name)
+
+        else:
+            # For other OSes we can just use peer auth.
+            method = "peer"
+            run(pg_ctl, "-D", datadir, "init")
+
+        with open(datadir / "pg_hba.conf", "w") as f:
+            print(f"# default: local {method} connections only", file=f)
+            print(f"local all all {method}", file=f)
+
+        # Figure out a port to listen on. Attempt to reserve both IPv4 and IPv6
+        # addresses in one go.
+        #
+        # Note: socket.has_dualstack_ipv6/create_server are only in Python 3.8+.
+        if hasattr(socket, "has_dualstack_ipv6") and socket.has_dualstack_ipv6():
+            addr = ("::1", 0)
+            s = socket.create_server(addr, family=socket.AF_INET6, dualstack_ipv6=True)
+
+            hostaddr, port, _, _ = s.getsockname()
+            addrs = [hostaddr, "127.0.0.1"]
+
+        else:
+            addr = ("127.0.0.1", 0)
+
+            s = socket.socket()
+            s.bind(addr)
+
+            hostaddr, port = s.getsockname()
+            addrs = [hostaddr]
+
+        log = self._log
+
+        with s, open(os.path.join(datadir, "postgresql.conf"), "a") as f:
+            print(file=f)
+            print("unix_socket_directories = '{}'".format(sockdir.as_posix()), file=f)
+            print("listen_addresses = '{}'".format(",".join(addrs)), file=f)
+            print("port =", port, file=f)
+            print("log_connections = all", file=f)
+
+        # Between closing of the socket, s, and server start, we're racing against
+        # anything that wants to open up ephemeral ports, so try not to put any new
+        # work here.
+
+        run(pg_ctl, "-D", datadir, "-l", log, "start")
+
+        # Read the PID file to get the postmaster PID
+        with open(os.path.join(datadir, "postmaster.pid")) as f:
+            pid = int(f.readline().strip())
+
+        # Store the computed values
+        self.hostaddr = hostaddr
+        self.port = port
+        self.pid = pid
+
+        # ExitStack for cleanup callbacks
+        self._cleanup_stack = contextlib.ExitStack()
+
+    def psql(self, *args):
+        """Run psql with the given arguments."""
+        if platform.system() == "Windows":
+            pw = dict(PGPASSWORD=self._winpassword)
+        else:
+            pw = None
+        self._run(os.path.join(self._bindir, "psql"), "-w", *args, addenv=pw)
+
+    def pg_ctl(self, *args):
+        """Run pg_ctl with the given arguments."""
+        self._run(self._pg_ctl, "-l", self._log, *args)
+
+    def _run(self, cmd, *args, addenv: Optional[dict] = None):
+        """Run a command with PG* environment variables set."""
+        subenv = dict(os.environ)
+        subenv.update(
+            {
+                "PGHOST": str(self.sockdir),
+                "PGPORT": str(self.port),
+                "PGDATABASE": "postgres",
+                "PGDATA": str(self.datadir),
+            }
+        )
+        if addenv:
+            subenv.update(addenv)
+        run(cmd, *args, env=subenv)
+
+    def create_users(self, *userkeys: str):
+        """Create test users and register them for cleanup."""
+        usermap = {}
+        for u in userkeys:
+            name = u + "user"
+            usermap[u] = name
+            self.psql("-c", "CREATE USER " + name)
+            self._cleanup_stack.callback(self.psql, "-c", "DROP USER " + name)
+        return usermap
+
+    def create_dbs(self, *dbkeys: str):
+        """Create test databases and register them for cleanup."""
+        dbmap = {}
+        for d in dbkeys:
+            name = d + "db"
+            dbmap[d] = name
+            self.psql("-c", "CREATE DATABASE " + name)
+            self._cleanup_stack.callback(self.psql, "-c", "DROP DATABASE " + name)
+        return dbmap
+
+    @contextlib.contextmanager
+    def reloading(self):
+        """
+        Provides a context manager for making configuration changes.
+
+        If the context suite finishes successfully, the configuration will
+        be reloaded via pg_ctl. On teardown, the configuration changes will
+        be unwound, and the server will be signaled to reload again.
+
+        The context target contains the following attributes which can be
+        used to configure the server:
+        - .conf: modifies postgresql.conf
+        - .hba: modifies pg_hba.conf
+
+        For example:
+
+            with pg_server_session.reloading() as s:
+                s.conf.set(log_connections="on")
+                s.hba.prepend("local all all trust")
+        """
+        # Push a reload onto the stack before making any other
+        # unwindable changes. That way the order of operations will be
+        #
+        #  # test
+        #   - config change 1
+        #   - config change 2
+        #   - reload
+        #  # teardown
+        #   - undo config change 2
+        #   - undo config change 1
+        #   - reload
+        #
+        self._cleanup_stack.callback(self.pg_ctl, "reload")
+        yield self._backup_configuration()
+
+        # Now actually reload
+        self.pg_ctl("reload")
+
+    @contextlib.contextmanager
+    def restarting(self):
+        """Like .reloading(), but with a full server restart."""
+        self._cleanup_stack.callback(self.pg_ctl, "restart")
+        yield self._backup_configuration()
+        self.pg_ctl("restart")
+
+    def _backup_configuration(self):
+        # Wrap the existing HBA and configuration with FileBackups.
+        return Backup(
+            hba=self._cleanup_stack.enter_context(HBA(self.datadir)),
+            conf=self._cleanup_stack.enter_context(Config(self.datadir)),
+        )
+
+    @contextlib.contextmanager
+    def subcontext(self):
+        """
+        Create a new cleanup context for per-test isolation.
+
+        Temporarily replaces the cleanup stack so that any cleanup callbacks
+        registered within this context will be cleaned up when the context exits.
+        """
+        old_stack = self._cleanup_stack
+        self._cleanup_stack = contextlib.ExitStack()
+        try:
+            self._cleanup_stack.__enter__()
+            yield self
+        finally:
+            self._cleanup_stack.__exit__(None, None, None)
+            self._cleanup_stack = old_stack
+
+    def stop(self):
+        """
+        Stop the PostgreSQL server instance.
+
+        Ignores failures if the server is already stopped.
+        """
+        try:
+            run(self._pg_ctl, "-D", self.datadir, "-l", self._log, "stop")
+        except subprocess.CalledProcessError:
+            # Server may have already been stopped
+            pass
+
+    def cleanup(self):
+        """Run all registered cleanup callbacks."""
+        self._cleanup_stack.close()
+
+    def set_timeout(self, remaining_timeout_fn: Callable[[], float]) -> None:
+        """
+        Set the timeout function for connections.
+        This is typically called by pg fixture for each test.
+        """
+        self._remaining_timeout_fn = remaining_timeout_fn
+
+    def connect(self, **opts) -> PGconn:
+        """
+        Creates a connection to this PostgreSQL server instance.
+
+        This is a convenience method that automatically fills in the host, port,
+        and dbname (defaulting to 'postgres') for connecting to this server.
+
+        Args:
+            stack: ExitStack for managing connection cleanup (uses internal stack if not provided)
+            remaining_timeout_fn: Function that returns remaining timeout (uses stored timeout if not provided)
+            **opts: Additional connection options (can override defaults)
+
+        Returns:
+            PGconn: Connected database connection
+
+        Example:
+            conn = pg.connect()
+            conn = pg.connect(dbname='mydb')
+        """
+        from libpq import connect as libpq_connect
+
+        # Set default connection options for this server
+        defaults = {
+            "host": str(self.sockdir),
+            "port": self.port,
+            "dbname": "postgres",
+        }
+
+        # On Windows, include the password for SCRAM authentication
+        if platform.system() == "Windows" and self._winpassword:
+            defaults["password"] = self._winpassword
+
+        # Merge with user-provided options (user options take precedence)
+        defaults.update(opts)
+
+        if self._remaining_timeout_fn is None:
+            raise RuntimeError(
+                "Timeout function not set. Use set_timeout() or pg fixture."
+            )
+
+        return libpq_connect(
+            self.libpq_handle,
+            self._cleanup_stack,
+            self._remaining_timeout_fn,
+            **defaults,
+        )
diff --git a/src/test/pytest/pypg/util.py b/src/test/pytest/pypg/util.py
new file mode 100644
index 00000000000..b2a1e627e4b
--- /dev/null
+++ b/src/test/pytest/pypg/util.py
@@ -0,0 +1,42 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+import shlex
+import subprocess
+import sys
+
+
+def eprint(*args, **kwargs):
+    """eprint prints to stderr"""
+    print(*args, file=sys.stderr, **kwargs)
+
+
+def run(*command, check=True, shell=None, silent=False, **kwargs):
+    """run runs the given command and prints it to stderr"""
+
+    if shell is None:
+        shell = len(command) == 1 and isinstance(command[0], str)
+
+    if shell:
+        command = command[0]
+    else:
+        command = list(map(str, command))
+
+    if not silent:
+        if shell:
+            eprint(f"+ {command}")
+        else:
+            # We could normally use shlex.join here, but it's not available in
+            # Python 3.6 which we still like to support
+            unsafe_string_cmd = " ".join(map(shlex.quote, command))
+            eprint(f"+ {unsafe_string_cmd}")
+
+    if silent:
+        kwargs.setdefault("stdout", subprocess.DEVNULL)
+
+    return subprocess.run(command, check=check, shell=shell, **kwargs)
+
+
+def capture(command, *args, stdout=subprocess.PIPE, encoding="utf-8", **kwargs):
+    return run(
+        command, *args, stdout=stdout, encoding=encoding, **kwargs
+    ).stdout.removesuffix("\n")
diff --git a/src/test/pytest/pyt/conftest.py b/src/test/pytest/pyt/conftest.py
new file mode 100644
index 00000000000..e750ac080b5
--- /dev/null
+++ b/src/test/pytest/pyt/conftest.py
@@ -0,0 +1,4 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+
+pytest_plugins = ["pypg.fixtures"]
diff --git a/src/test/pytest/pyt/test_errors.py b/src/test/pytest/pyt/test_errors.py
new file mode 100644
index 00000000000..ad109039668
--- /dev/null
+++ b/src/test/pytest/pyt/test_errors.py
@@ -0,0 +1,34 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+"""
+Tests for libpq error types and SQLSTATE-based exception mapping.
+"""
+
+import pytest
+import libpq
+
+
+def test_syntax_error(conn):
+    """Invalid SQL syntax raises SyntaxError with correct SQLSTATE."""
+    with pytest.raises(libpq.errors.SyntaxError) as exc_info:
+        conn.sql("SELEC 1")
+
+    err = exc_info.value
+    assert err.sqlstate == "42601"
+    assert err.sqlstate_class == "42"
+    assert "syntax" in str(err).lower()
+
+
+def test_unique_violation(conn):
+    """Unique violation includes all error fields and can be caught as parent class."""
+    conn.sql("CREATE TEMP TABLE test_uv (id int CONSTRAINT test_uv_pk PRIMARY KEY)")
+    conn.sql("INSERT INTO test_uv VALUES (1)")
+
+    with pytest.raises(libpq.errors.UniqueViolation) as exc_info:
+        conn.sql("INSERT INTO test_uv VALUES (1)")
+
+    err = exc_info.value
+    assert err.sqlstate == "23505"
+    assert err.table_name == "test_uv"
+    assert err.constraint_name == "test_uv_pk"
+    assert err.detail == "Key (id)=(1) already exists."
diff --git a/src/test/pytest/pyt/test_libpq.py b/src/test/pytest/pyt/test_libpq.py
new file mode 100644
index 00000000000..4fcf4056f41
--- /dev/null
+++ b/src/test/pytest/pyt/test_libpq.py
@@ -0,0 +1,172 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+import contextlib
+import os
+import socket
+import struct
+import threading
+from typing import Callable
+
+import pytest
+
+from libpq import connstr, LibpqError
+
+
+@pytest.mark.parametrize(
+    "opts, expected",
+    [
+        (dict(), ""),
+        (dict(port=5432), "port=5432"),
+        (dict(port=5432, dbname="postgres"), "port=5432 dbname=postgres"),
+        (dict(host=""), "host=''"),
+        (dict(host=" "), r"host=' '"),
+        (dict(keyword="'"), r"keyword=\'"),
+        (dict(keyword=" \\' "), r"keyword=' \\\' '"),
+    ],
+)
+def test_connstr(opts, expected):
+    """Tests the escape behavior for connstr()."""
+    assert connstr(opts) == expected
+
+
+def test_must_connect_errors(connect):
+    """Tests that connect() raises LibpqError."""
+    with pytest.raises(LibpqError, match="invalid connection option"):
+        connect(some_unknown_keyword="whatever")
+
+
+@pytest.fixture
+def local_server(tmp_path, remaining_timeout):
+    """
+    Opens up a local UNIX socket for mocking a Postgres server on a background
+    thread. See the _Server API for usage.
+
+    This fixture requires AF_UNIX support; dependent tests will be skipped on
+    platforms that don't provide it.
+    """
+
+    try:
+        from socket import AF_UNIX
+    except ImportError:
+        pytest.skip("AF_UNIX not supported on this platform")
+
+    class _Server(contextlib.ExitStack):
+        """
+        Implementation class for local_server. See .background() for the primary
+        entry point for tests. Postgres clients may connect to this server via
+        local_server.host/local_server.port.
+
+        _Server derives from contextlib.ExitStack to provide easy cleanup of
+        associated resources; see the documentation for that class for a full
+        explanation.
+        """
+
+        def __init__(self):
+            super().__init__()
+
+            self.host = tmp_path
+            self.port = 5432
+
+            self._thread = None
+            self._thread_exc = None
+            self._listener = self.enter_context(
+                socket.socket(AF_UNIX, socket.SOCK_STREAM),
+            )
+
+        def bind_and_listen(self):
+            """
+            Does the actual work of binding the UNIX socket using the Postgres
+            server conventions and listening for connections.
+
+            The listen backlog is currently hardcoded to one.
+            """
+            sockfile = self.host / ".s.PGSQL.{}".format(self.port)
+
+            # Lock down the permissions on the new socket.
+            prev_mask = os.umask(0o077)
+
+            # Bind (creating the socket file), and immediately register it for
+            # deletion from disk when the stack is cleaned up.
+            self._listener.bind(bytes(sockfile))
+            self.callback(os.unlink, sockfile)
+
+            os.umask(prev_mask)
+
+            self._listener.listen(1)
+
+        def background(self, fn: Callable[[socket.socket], None]) -> None:
+            """
+            Accepts a client connection on a background thread and passes it to
+            the provided callback. Any exceptions raised from the callback will
+            be re-raised on the main thread during fixture teardown.
+
+            Blocking operations on the connected socket default to using the
+            remaining_timeout(), though this can be changed by the test via the
+            socket's .settimeout().
+            """
+
+            def _bg():
+                try:
+                    self._listener.settimeout(remaining_timeout())
+                    sock, _ = self._listener.accept()
+
+                    with sock:
+                        sock.settimeout(remaining_timeout())
+                        fn(sock)
+
+                except Exception as e:
+                    # Save the exception for re-raising on the main thread.
+                    self._thread_exc = e
+
+            # TODO: rather than using callback(), consider explicitly signaling
+            # the fn() implementation to stop early if we get an exception.
+            # Otherwise we'll hang until the end of the timeout.
+            self._thread = threading.Thread(target=_bg)
+            self.callback(self._join)
+
+            self._thread.start()
+
+        def _join(self):
+            """
+            Waits for the background thread to finish and raises any thrown
+            exception. This is called during fixture teardown.
+            """
+            # Give a little bit of wiggle room on the join timeout, since we're
+            # racing against the test's own use of remaining_timeout(). (It's
+            # preferable to let tests report timeouts; the stack traces will
+            # help with debugging.)
+            self._thread.join(remaining_timeout() + 1)
+            if self._thread.is_alive():
+                raise TimeoutError("background thread is still running after timeout")
+
+            if self._thread_exc is not None:
+                raise self._thread_exc
+
+    with _Server() as s:
+        s.bind_and_listen()
+        yield s
+
+
+def test_connection_is_finished_on_error(connect, local_server):
+    """Tests that PQfinish() gets called at the end of testing."""
+    expected_error = "something is wrong"
+
+    def serve_error(s: socket.socket) -> None:
+        pktlen = struct.unpack("!I", s.recv(4))[0]
+
+        # Quick check for the startup packet version.
+        version = struct.unpack("!HH", s.recv(4))
+        assert version == (3, 0)
+
+        # Discard the remainder of the startup packet and send a v2 error.
+        s.recv(pktlen - 8)
+        s.send(b"E" + expected_error.encode() + b"\0")
+
+        # And now the socket should be closed.
+        assert not s.recv(1), "client sent unexpected data"
+
+    local_server.background(serve_error)
+
+    with pytest.raises(LibpqError, match=expected_error):
+        # Exiting this context should result in PQfinish().
+        connect(host=local_server.host, port=local_server.port)
diff --git a/src/test/pytest/pyt/test_query_helpers.py b/src/test/pytest/pyt/test_query_helpers.py
new file mode 100644
index 00000000000..5a5a1ae1edf
--- /dev/null
+++ b/src/test/pytest/pyt/test_query_helpers.py
@@ -0,0 +1,286 @@
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+"""
+Tests for query helper functions with type conversion and result simplification.
+"""
+
+import pytest
+
+
+def test_single_cell_int(conn):
+    """Single cell integer query returns just the value."""
+    result = conn.sql("SELECT 1")
+    assert result == 1
+    assert isinstance(result, int)
+
+
+def test_single_cell_string(conn):
+    """Single cell string query returns just the value."""
+    result = conn.sql("SELECT 'hello'")
+    assert result == "hello"
+    assert isinstance(result, str)
+
+
+def test_single_cell_bool(conn):
+    """Single cell boolean query returns just the value."""
+
+    result = conn.sql("SELECT true")
+    assert result is True
+    assert isinstance(result, bool)
+
+    result = conn.sql("SELECT false")
+    assert result is False
+
+
+def test_single_cell_float(conn):
+    """Single cell float query returns just the value."""
+
+    result = conn.sql("SELECT 3.14::float4")
+    assert isinstance(result, float)
+    assert abs(result - 3.14) < 0.01
+
+
+def test_single_cell_null(conn):
+    """Single cell NULL query returns None."""
+
+    result = conn.sql("SELECT NULL")
+    assert result is None
+
+
+def test_single_row_multiple_columns(conn):
+    """Single row with multiple columns returns a tuple."""
+
+    result = conn.sql("SELECT 1, 'hello', true")
+    assert result == (1, "hello", True)
+    assert isinstance(result, tuple)
+
+
+def test_single_column_multiple_rows(conn):
+    """Single column with multiple rows returns a list of values."""
+
+    result = conn.sql("SELECT * FROM generate_series(1, 3)")
+    assert result == [1, 2, 3]
+    assert isinstance(result, list)
+
+
+def test_multiple_rows_and_columns(conn):
+    """Multiple rows and columns returns list of tuples."""
+
+    result = conn.sql("SELECT * FROM (VALUES (1, 'a'), (2, 'b'), (3, 'c')) AS t")
+    assert result == [(1, "a"), (2, "b"), (3, "c")]
+    assert isinstance(result, list)
+    assert all(isinstance(row, tuple) for row in result)
+
+
+def test_empty_result(conn):
+    """Empty result set returns empty list."""
+
+    result = conn.sql("SELECT 1 WHERE false")
+    assert result == []
+
+
+def test_query_error_handling(conn):
+    """Query errors raise RuntimeError with actual error message."""
+
+    with pytest.raises(RuntimeError) as exc_info:
+        conn.sql("SELECT * FROM nonexistent_table")
+
+    error_msg = str(exc_info.value)
+    assert "nonexistent_table" in error_msg or "does not exist" in error_msg
+
+
+def test_division_by_zero_error(conn):
+    """Division by zero raises RuntimeError."""
+
+    with pytest.raises(RuntimeError) as exc_info:
+        conn.sql("SELECT 1/0")
+
+    error_msg = str(exc_info.value)
+    assert "division by zero" in error_msg.lower()
+
+
+def test_simple_exec_create_table(conn):
+    """sql for CREATE TABLE returns None."""
+
+    result = conn.sql("CREATE TEMP TABLE test_table (id int, name text)")
+    assert result is None
+
+    # Verify table was created
+    count = conn.sql("SELECT COUNT(*) FROM test_table")
+    assert count == 0
+
+
+def test_simple_exec_insert(conn):
+    """sql for INSERT returns None."""
+
+    conn.sql("CREATE TEMP TABLE test_table (id int, name text)")
+    result = conn.sql("INSERT INTO test_table VALUES (1, 'Alice'), (2, 'Bob')")
+    assert result is None
+
+    # Verify data was inserted
+    count = conn.sql("SELECT COUNT(*) FROM test_table")
+    assert count == 2
+
+
+def test_type_conversion_mixed(conn):
+    """Test mixed type conversion in a single row."""
+
+    result = conn.sql(
+        "SELECT 42::int4, 123::int8, 3.14::float8, 'text', true, NULL"
+    )
+    assert result == (42, 123, 3.14, "text", True, None)
+    assert isinstance(result[0], int)
+    assert isinstance(result[1], int)
+    assert isinstance(result[2], float)
+    assert isinstance(result[3], str)
+    assert isinstance(result[4], bool)
+    assert result[5] is None
+
+
+def test_multiple_queries_same_connection(conn):
+    """Test running multiple queries on the same connection."""
+
+    result1 = conn.sql("SELECT 1")
+    assert result1 == 1
+
+    result2 = conn.sql("SELECT 'hello', 'world'")
+    assert result2 == ("hello", "world")
+
+    result3 = conn.sql("SELECT * FROM generate_series(1, 5)")
+    assert result3 == [1, 2, 3, 4, 5]
+
+
+def test_date_type(conn):
+    """Test date type conversion."""
+    import datetime
+
+    result = conn.sql("SELECT '2025-10-20'::date")
+    assert result == datetime.date(2025, 10, 20)
+    assert isinstance(result, datetime.date)
+
+
+def test_timestamp_type(conn):
+    """Test timestamp type conversion."""
+    import datetime
+
+    result = conn.sql("SELECT '2025-10-20 15:30:45'::timestamp")
+    assert result == datetime.datetime(2025, 10, 20, 15, 30, 45)
+    assert isinstance(result, datetime.datetime)
+
+
+def test_time_type(conn):
+    """Test time type conversion."""
+    import datetime
+
+    result = conn.sql("SELECT '15:30:45'::time")
+    assert result == datetime.time(15, 30, 45)
+    assert isinstance(result, datetime.time)
+
+
+def test_numeric_type(conn):
+    """Test numeric/decimal type conversion."""
+    import decimal
+
+    result = conn.sql("SELECT 123.456::numeric")
+    assert result == decimal.Decimal("123.456")
+    assert isinstance(result, decimal.Decimal)
+
+
+def test_int_array(conn):
+    """Test integer array type conversion."""
+
+    result = conn.sql("SELECT ARRAY[1, 2, 3, 4, 5]")
+    assert result == [1, 2, 3, 4, 5]
+    assert isinstance(result, list)
+    assert all(isinstance(x, int) for x in result)
+
+
+def test_text_array(conn):
+    """Test text array type conversion."""
+
+    result = conn.sql("SELECT ARRAY['hello', 'world', 'test']")
+    assert result == ["hello", "world", "test"]
+    assert isinstance(result, list)
+    assert all(isinstance(x, str) for x in result)
+
+
+def test_bool_array(conn):
+    """Test boolean array type conversion."""
+
+    result = conn.sql("SELECT ARRAY[true, false, true]")
+    assert result == [True, False, True]
+    assert isinstance(result, list)
+    assert all(isinstance(x, bool) for x in result)
+
+
+def test_empty_array(conn):
+    """Test empty array type conversion."""
+
+    result = conn.sql("SELECT ARRAY[]::int[]")
+    assert result == []
+    assert isinstance(result, list)
+
+
+def test_json_type(conn):
+    """Test JSON type (parsed to dict)."""
+
+    result = conn.sql('SELECT \'{"key": "value"}\'::json')
+    assert isinstance(result, dict)
+    assert result == {"key": "value"}
+
+
+def test_jsonb_type(conn):
+    """Test JSONB type (parsed to dict)."""
+
+    result = conn.sql('SELECT \'{"name": "test", "count": 42}\'::jsonb')
+    assert isinstance(result, dict)
+    assert result == {"name": "test", "count": 42}
+
+
+def test_json_array(conn):
+    """Test JSON array type."""
+
+    result = conn.sql("SELECT '[1, 2, 3, 4, 5]'::json")
+    assert isinstance(result, list)
+    assert result == [1, 2, 3, 4, 5]
+
+
+def test_json_nested(conn):
+    """Test nested JSON object."""
+
+    result = conn.sql(
+        'SELECT \'{"user": {"id": 1, "name": "Alice"}, "active": true}\'::json'
+    )
+    assert isinstance(result, dict)
+    assert result == {"user": {"id": 1, "name": "Alice"}, "active": True}
+
+
+def test_mixed_types_with_arrays(conn):
+    """Test mixed types including arrays in a single row."""
+
+    result = conn.sql("SELECT 42, 'text', ARRAY[1, 2, 3], true")
+    assert result == (42, "text", [1, 2, 3], True)
+    assert isinstance(result[0], int)
+    assert isinstance(result[1], str)
+    assert isinstance(result[2], list)
+    assert isinstance(result[3], bool)
+
+
+def test_uuid_type(conn):
+    """Test UUID type conversion."""
+    import uuid
+
+    test_uuid = "550e8400-e29b-41d4-a716-446655440000"
+    result = conn.sql(f"SELECT '{test_uuid}'::uuid")
+    assert result == uuid.UUID(test_uuid)
+    assert isinstance(result, uuid.UUID)
+
+
+def test_uuid_generation(conn):
+    """Test generated UUID type conversion."""
+    import uuid
+
+    result = conn.sql("SELECT uuidv4()")
+    assert isinstance(result, uuid.UUID)
+    # Check it's a valid UUID by ensuring it can be converted to string
+    assert len(str(result)) == 36  # UUID string format length
diff --git a/src/tools/generate_pytest_libpq_errors.py b/src/tools/generate_pytest_libpq_errors.py
new file mode 100755
index 00000000000..ba92891c17a
--- /dev/null
+++ b/src/tools/generate_pytest_libpq_errors.py
@@ -0,0 +1,147 @@
+#!/usr/bin/env python3
+# Copyright (c) 2025, PostgreSQL Global Development Group
+
+"""
+Generate src/test/pytest/libpq/_generated_errors.py from errcodes.txt.
+"""
+
+import sys
+from pathlib import Path
+
+
+ACRONYMS = {"sql", "fdw"}
+WORD_MAP = {
+    "sqlclient": "SQLClient",
+    "sqlserver": "SQLServer",
+    "sqlconnection": "SQLConnection",
+}
+
+
+def snake_to_pascal(name: str) -> str:
+    """Convert snake_case to PascalCase, keeping acronyms uppercase."""
+    words = []
+    for word in name.split("_"):
+        if word in WORD_MAP:
+            words.append(WORD_MAP[word])
+        elif word in ACRONYMS:
+            words.append(word.upper())
+        else:
+            words.append(word.capitalize())
+    return "".join(words)
+
+
+def parse_errcodes(path: Path):
+    """Parse errcodes.txt and return list of (sqlstate, macro_name, spec_name) tuples."""
+    errors = []
+
+    with open(path) as f:
+        for line in f:
+            parts = line.split()
+            if len(parts) >= 4 and len(parts[0]) == 5:
+                sqlstate, _, macro_name, spec_name = parts[:4]
+                errors.append((sqlstate, macro_name, spec_name))
+
+    return errors
+
+
+def macro_to_class_name(macro_name: str) -> str:
+    """Convert ERRCODE_FOO_BAR to FooBar."""
+    name = macro_name.removeprefix("ERRCODE_")
+    # Move WARNING prefix to the end as a suffix
+    if name.startswith("WARNING_"):
+        name = name.removeprefix("WARNING_") + "_WARNING"
+    return snake_to_pascal(name.lower())
+
+
+def generate_errors(errcodes_path: Path):
+    """Generate the _generated_errors.py content."""
+    errors = parse_errcodes(errcodes_path)
+
+    # Find spec_names that appear more than once (collisions)
+    spec_name_counts: dict[str, int] = {}
+    for _, _, spec_name in errors:
+        spec_name_counts[spec_name] = spec_name_counts.get(spec_name, 0) + 1
+    colliding_spec_names = {
+        name for name, count in spec_name_counts.items() if count > 1
+    }
+
+    lines = [
+        "# Copyright (c) 2025, PostgreSQL Global Development Group",
+        "# This file is generated by src/tools/generate_pytest_libpq_errors.py - do not edit directly.",
+        "",
+        '"""',
+        "Generated PostgreSQL error classes mapped from SQLSTATE codes.",
+        '"""',
+        "",
+        "from typing import Dict",
+        "",
+        "from ._error_base import LibpqError, LibpqWarning",
+        "",
+        "",
+    ]
+
+    generated_classes = {"LibpqError"}
+    sqlstate_to_exception = {}
+
+    for sqlstate, macro_name, spec_name in errors:
+        # 000 errors define the parent class for all errors in this SQLSTATE class
+        if sqlstate.endswith("000"):
+            exc_name = snake_to_pascal(spec_name)
+            if exc_name == "Warning":
+                parent = "LibpqWarning"
+            else:
+                parent = "LibpqError"
+        else:
+            if spec_name in colliding_spec_names:
+                exc_name = macro_to_class_name(macro_name)
+            else:
+                exc_name = snake_to_pascal(spec_name)
+            # Use parent class if available, otherwise LibpqError
+            parent = sqlstate_to_exception.get(sqlstate[:2] + "000", "LibpqError")
+            # Warnings should end with "Warning"
+            if parent == "Warning" and not exc_name.endswith("Warning"):
+                exc_name += "Warning"
+
+        generated_classes.add(exc_name)
+        sqlstate_to_exception[sqlstate] = exc_name
+        lines.extend(
+            [
+                f"class {exc_name}({parent}):",
+                f'    """SQLSTATE {sqlstate} - {spec_name.replace("_", " ")}."""',
+                "",
+                "    pass",
+                "",
+                "",
+            ]
+        )
+
+    lines.append("SQLSTATE_TO_EXCEPTION: Dict[str, type] = {")
+    for sqlstate, exc_name in sqlstate_to_exception.items():
+        lines.append(f'    "{sqlstate}": {exc_name},')
+    lines.extend(["}", "", ""])
+
+    all_exports = list(generated_classes) + ["SQLSTATE_TO_EXCEPTION"]
+    lines.append("__all__ = [")
+    for name in all_exports:
+        lines.append(f'    "{name}",')
+    lines.append("]")
+
+    return "\n".join(lines) + "\n"
+
+
+if __name__ == "__main__":
+    script_dir = Path(__file__).resolve().parent
+    src_root = script_dir.parent.parent
+
+    errcodes_path = src_root / "src" / "backend" / "utils" / "errcodes.txt"
+    output_path = (
+        src_root / "src" / "test" / "pytest" / "libpq" / "_generated_errors.py"
+    )
+
+    if not errcodes_path.exists():
+        print(f"Error: {errcodes_path} not found", file=sys.stderr)
+        sys.exit(1)
+
+    output = generate_errors(errcodes_path)
+    output_path.write_text(output)
+    print(f"Generated {output_path}")
-- 
2.52.0

