Bläddra i källkod

honor per-column binary result formats in the extended protocol

Bind accepts per-column result formats (0=text, 1=binary) and stores the raw
list on the portal, so two portals over one prepared statement can use
different formats. Execute resolves the list against the produced
RowDescription: an empty list is all text, a single entry repeats, and any
other length must equal the column count (08P01); a binary column whose OID
has no encoder fails with 0A000 before any result frame. Describe keeps
returning NoData and simple-query results stay text.

DataRow values encode in PostgreSQL binary form for bool, bytea, int2/int4/
int8, float4/float8, text/varchar, numeric, jsonb, and one-dimensional arrays,
with NULL as length -1 and empty arrays encoded without a dimension header.
Numeric digits are taken from exact decimal text in base-10000 groups rather
than float64, so numeric array elements keep their scale and full precision.
Binary payloads are appended directly into the DataRow buffer.

pgx end-to-end tests cover explicit QueryResultFormats in the default query
mode, mixed text/binary columns, RETURNING, parameters, NULLs, empty arrays,
and NULL elements. The capability document replaces the all-text rejection
with the supported binary result list and documents that a rejected
DML ... RETURNING request runs before the format is resolved and is not rolled
back.
Danilo Fragoso 1 dag sedan
förälder
incheckning
d2114d35fc

+ 1 - 1
pkg/capabilities/capabilities.go

@@ -161,7 +161,7 @@ func Current() *Document {
 			SimpleQuery:    true,
 			ExtendedQuery:  true,
 			Describe:       Feature{Supported: true, Status: "compatibility_no_data", Notes: "statement Describe returns ParameterDescription with the negotiated parameter types (a client Parse OID wins; an OID of 0 is inferred from casts, comparisons, assignment targets, and ANY/ALL operands, and falls back to TEXT rather than 0) then NoData; portal Describe returns NoData without a ParameterDescription, matching PostgreSQL; Execute still emits the real RowDescription; a placeholder number above the 65535-parameter protocol limit fails Parse with 54000 before any allocation"},
-			BinaryResults:  Feature{Supported: false, Status: "rejected", SQLState: "0A000", Notes: "all-text result formats only; per-column format lists are also rejected"},
+			BinaryResults:  Feature{Supported: true, Status: "supported", Notes: "Bind accepts per-column result formats (0=text, 1=binary): an empty list is all text, a single entry applies to every column, and any other length must equal the result column count (08P01 otherwise); binary is encodable for bool (16), bytea (17), int8 (20), int2 (21), int4 (23), text (25), float4 (700), float8 (701), varchar (1043), numeric (1700), jsonb (3802, version byte 0x01 plus canonical JSON text), and one-dimensional arrays (1000, 1005, 1007, 1009, 1015, 1016, 1021, 1022, 1231) with NULL elements and empty arrays preserved; a binary request for any other reported OID (timestamptz 1184, date 1082) fails with 0A000; formats are resolved when Execute produces the result because Describe returns NoData, so for INSERT/UPDATE/DELETE ... RETURNING the statement runs before a bad format or OID is rejected (no rows are sent and no rollback is attempted); Describe and simple-query results stay text, and numeric array elements are encoded exactly from their stored decimal text"},
 			BoundedExecute: Feature{Supported: false, Status: "rejected", SQLState: "0A000", Notes: "Execute maxRows must be zero; portals are one-shot, not resumable"},
 			ProxyErrors:    true,
 			TLS:            "terminated by the managed pgproxy, not the engine listener",

+ 22 - 0
pkg/capabilities/capabilities_test.go

@@ -300,3 +300,25 @@ func TestJSONBIsAdvertised(t *testing.T) {
 		t.Fatalf("22023 mapping = %q, want invalid_parameter_value", got)
 	}
 }
+
+// Binary result support is advertised with the encodable OIDs and the SQLSTATE
+// for a type that has no binary encoder. pkg/pgserver covers each claim.
+func TestBinaryResultsAreAdvertised(t *testing.T) {
+	results := Current().Protocol.BinaryResults
+	if !results.Supported {
+		t.Fatalf("binary_results = %+v, want supported", results)
+	}
+	if results.Status != "supported" {
+		t.Fatalf("binary_results status = %q, want supported", results.Status)
+	}
+	for _, claim := range []string{
+		"bool (16)", "bytea (17)", "int8 (20)", "int2 (21)", "int4 (23)", "text (25)",
+		"float4 (700)", "float8 (701)", "varchar (1043)", "numeric (1700)", "jsonb (3802,",
+		"1000, 1005, 1007, 1009, 1015, 1016, 1021, 1022, 1231", "0A000",
+		"Describe and simple-query results stay text",
+	} {
+		if !strings.Contains(results.Notes, claim) {
+			t.Errorf("binary_results notes do not mention %q", claim)
+		}
+	}
+}

+ 8 - 0
pkg/executor/arrays.go

@@ -220,6 +220,14 @@ func CanonicalizeArrayText(text, typeName string) (string, error) {
 	return canonicalArrayText(text, typeName)
 }
 
+// ParseArrayText splits stored array text into its element strings, with nil for
+// a SQL NULL element. The wire layer reuses it to encode the same text in the
+// PostgreSQL binary array format, so both formats are produced from one
+// canonical representation.
+func ParseArrayText(text string) ([]*string, error) {
+	return parseArrayLiteral(text)
+}
+
 // canonicalArrayText parses array text and re-encodes it for the given array
 // type, converting each element to its canonical spelling.
 func canonicalArrayText(text, typeName string) (string, error) {

+ 96 - 37
pkg/pgserver/connection.go

@@ -72,6 +72,10 @@ type preparedStatement struct {
 type portal struct {
 	query    string
 	executed bool
+	// resultFormats is the Bind result format list exactly as the client sent
+	// it: empty (all text), one entry that repeats for every column, or one
+	// entry per column. It is resolved against the produced result at Execute.
+	resultFormats []int16
 }
 
 // NewConnection creates a new connection handler
@@ -385,7 +389,7 @@ func (c *Connection) handleQuery(msg *Message) error {
 			c.sendError("ERROR", ErrCodeInternalError, err.Error())
 			return c.sendReadyForQuery()
 		}
-		if err := c.sendTabularResult(result, "SELECT"); err != nil {
+		if err := c.sendTabularResult(result, "SELECT", nil); err != nil {
 			return err
 		}
 		return c.sendReadyForQuery()
@@ -497,7 +501,7 @@ func (c *Connection) handleQuery(msg *Message) error {
 			c.sendError("ERROR", code, message)
 			return c.sendReadyForQuery()
 		}
-		if err := c.sendResult(result, stmt); err != nil {
+		if err := c.sendResult(result, stmt, wireColumnTypes(result), nil); err != nil {
 			return err
 		}
 		if result.LSN > 0 {
@@ -600,12 +604,6 @@ func (c *Connection) handleBind(msg *Message) error {
 		if format != 0 && format != 1 {
 			return c.failExtended(ErrCodeProtocolViolation, fmt.Errorf("invalid Bind result format code"))
 		}
-		if format == 1 {
-			return c.failExtended(ErrCodeFeatureNotSupported, fmt.Errorf("binary result format is not supported; request text results"))
-		}
-	}
-	if len(resultFormats) > 1 {
-		return c.failExtended(ErrCodeFeatureNotSupported, fmt.Errorf("per-column result format lists are not supported; request all-text results"))
 	}
 	query, err := bindQuery(statement.query, params)
 	if err != nil {
@@ -621,7 +619,7 @@ func (c *Connection) handleBind(msg *Message) error {
 		}
 		return c.failExtended(code, err)
 	}
-	c.portals[portalName] = &portal{query: query}
+	c.portals[portalName] = &portal{query: query, resultFormats: resultFormats}
 	return c.writeMessage(MsgBindComplete, nil)
 }
 
@@ -694,7 +692,7 @@ func (c *Connection) handleExecute(msg *Message) error {
 		if catalogErr != nil {
 			return c.failExtended(ErrCodeInternalError, catalogErr)
 		}
-		return c.sendTabularResult(result, "SELECT")
+		return c.sendTabularResult(result, "SELECT", portal.resultFormats)
 	}
 	l := lexer.New(portal.query)
 	stmt, err := parser.New(l).Parse()
@@ -785,7 +783,12 @@ func (c *Connection) handleExecute(msg *Message) error {
 		}
 		return c.failExtended(code, fmt.Errorf("execution error: %w", err))
 	}
-	if err := c.sendResult(result, stmt); err != nil {
+	columnTypes := wireColumnTypes(result)
+	formats, formatErr := c.resolveResultFormats(portal.resultFormats, columnTypes, len(result.Columns))
+	if formatErr != nil {
+		return c.failResultFormat(formatErr)
+	}
+	if err := c.sendResult(result, stmt, columnTypes, formats); err != nil {
 		return err
 	}
 	if result.LSN > 0 {
@@ -991,13 +994,18 @@ func yesNo(value bool) string {
 	return "NO"
 }
 
-func (c *Connection) sendTabularResult(result *executor.Result, tag string) error {
-	if err := c.sendRowDescription(result.Columns, result.ColumnTypes); err != nil {
+func (c *Connection) sendTabularResult(result *executor.Result, tag string, bindFormats []int16) error {
+	formats, err := c.resolveResultFormats(bindFormats, result.ColumnTypes, len(result.Columns))
+	if err != nil {
+		return c.failResultFormat(err)
+	}
+	oids := c.resultColumnOIDs(result.ColumnTypes, len(result.Columns))
+	if err := c.sendRowDescriptionFormats(result.Columns, result.ColumnTypes, oids, formats); err != nil {
 		return err
 	}
 	for _, row := range result.Rows {
-		if err := c.sendDataRow(row, result.Columns); err != nil {
-			return err
+		if err := c.sendDataRowFormats(row, oids, formats); err != nil {
+			return c.failBinaryEncode(err)
 		}
 	}
 	return c.sendCommandComplete(fmt.Sprintf("%s %d", tag, len(result.Rows)))
@@ -1233,23 +1241,25 @@ func parameterLiteral(param boundParameter) (string, error) {
 	}
 }
 
-// sendResult sends query results
-func (c *Connection) sendResult(result *executor.Result, stmt parser.Statement) error {
+// sendResult sends a statement's result. columnTypes are the types reported in
+// RowDescription (already corrected for BLOB values) and formats are the
+// portal's resolved per-column format codes, or nil for all-text results.
+func (c *Connection) sendResult(result *executor.Result, stmt parser.Statement, columnTypes []string, formats []int16) error {
 	// Any statement that produced a row set (SELECT, or INSERT/UPDATE/DELETE
 	// with RETURNING) sends a row description and its data rows. Other
 	// statements only send the command completion tag.
 	if len(result.Columns) > 0 {
-		if err := c.sendRowDescription(result.Columns, wireColumnTypes(result)); err != nil {
+		oids := c.resultColumnOIDs(columnTypes, len(result.Columns))
+		if err := c.sendRowDescriptionFormats(result.Columns, columnTypes, oids, formats); err != nil {
 			return err
 		}
 		for _, row := range result.Rows {
-			if err := c.sendDataRow(row, result.Columns); err != nil {
-				return err
+			if err := c.sendDataRowFormats(row, oids, formats); err != nil {
+				return c.failBinaryEncode(err)
 			}
 		}
 		return c.sendCommandComplete(c.getCommandTag(stmt, result))
 	}
-
 	// For other statements, just send command complete
 	tag := c.getCommandTag(stmt, result)
 	return c.sendCommandComplete(tag)
@@ -1429,43 +1439,85 @@ func (c *Connection) sendParameterDescription(oids []int32) error {
 	return c.writeMessage(MsgParameterDescription, mb.Bytes())
 }
 
-// sendRowDescription sends row description (column metadata)
+// sendRowDescription sends row description (column metadata) in text format.
 func (c *Connection) sendRowDescription(columns []string, columnTypes []string) error {
+	return c.sendRowDescriptionFormats(columns, columnTypes, c.resultColumnOIDs(columnTypes, len(columns)), nil)
+}
+
+// sendRowDescriptionFormats sends row description with the per-column formats a
+// portal resolved at Bind. PostgreSQL reports the bound formats in a portal
+// RowDescription; this server emits its RowDescription at Execute instead of
+// Describe, so the portal's formats travel with the row metadata. OIDs are the
+// same for text and binary results.
+func (c *Connection) sendRowDescriptionFormats(columns []string, columnTypes []string, oids []int32, formats []int16) error {
 	mb := NewMessageBuilder()
 	mb.WriteInt16(int16(len(columns)))
 
 	for i, col := range columns {
-		colType := ""
-		if i < len(columnTypes) {
-			colType = columnTypes[i]
+		colType := typeNameAt(columnTypes, i)
+		format := int16(0)
+		if i < len(formats) {
+			format = formats[i]
 		}
 		mb.WriteString(col)
 		mb.WriteInt32(0)                             // table OID
 		mb.WriteInt16(0)                             // column attribute number
-		mb.WriteInt32(c.getOIDForType(colType))      // type OID
+		mb.WriteInt32(oids[i])                       // type OID
 		mb.WriteInt16(c.getTypeSizeForType(colType)) // type size
 		mb.WriteInt32(-1)                            // type modifier
-		mb.WriteInt16(0)                             // format code (text)
+		mb.WriteInt16(format)                        // format code
 	}
 
 	return c.writeMessage(MsgRowDescription, mb.Bytes())
 }
 
-// sendDataRow sends a data row
+// resultColumnOIDs returns the PostgreSQL OID reported for every result column.
+func (c *Connection) resultColumnOIDs(columnTypes []string, columnCount int) []int32 {
+	oids := make([]int32, columnCount)
+	for i := range oids {
+		oids[i] = c.getOIDForType(typeNameAt(columnTypes, i))
+	}
+	return oids
+}
+
+// sendDataRow sends a data row in text format
 func (c *Connection) sendDataRow(row []interface{}, columns []string) error {
+	return c.sendDataRowFormats(row, nil, nil)
+}
+
+// sendDataRowFormats sends one DataRow, choosing text or binary encoding per
+// column from formats. NULL is always length -1. Binary values are appended
+// directly into the DataRow buffer; a value that cannot be encoded fails before
+// the row is written, so the client never receives a partial DataRow.
+func (c *Connection) sendDataRowFormats(row []interface{}, oids []int32, formats []int16) error {
 	mb := NewMessageBuilder()
 	mb.WriteInt16(int16(len(row)))
 
-	for _, value := range row {
+	for i, value := range row {
 		if value == nil {
 			mb.WriteInt32(-1) // NULL indicator
 			continue
 		}
-
-		// Convert value to string
-		strValue := c.valueToString(value)
-		mb.WriteInt32(int32(len(strValue)))
-		mb.WriteBytes([]byte(strValue))
+		format := int16(0)
+		if i < len(formats) {
+			format = formats[i]
+		}
+		if format == 0 {
+			strValue := c.valueToString(value)
+			mb.WriteInt32(int32(len(strValue)))
+			mb.WriteBytes([]byte(strValue))
+			continue
+		}
+		oid := int32(25) // TEXT, the fallback RowDescription OID
+		if i < len(oids) {
+			oid = oids[i]
+		}
+		err := mb.AppendBinary(func(dst []byte) ([]byte, error) {
+			return appendBinaryResultValue(dst, oid, value)
+		})
+		if err != nil {
+			return fmt.Errorf("binary result column %d: %w", i+1, err)
+		}
 	}
 
 	return c.writeMessage(MsgDataRow, mb.Bytes())
@@ -1656,13 +1708,20 @@ func (c *Connection) getTypeSizeForType(typeName string) int16 {
 	}
 }
 
-// valueToString converts a value to string
+// valueToString converts a value to its PostgreSQL text representation
 func (c *Connection) valueToString(value interface{}) string {
+	return textValueString(value)
+}
+
+// textValueString is the text representation the wire layer sends for a value.
+// Binary text and bytea results reuse it so both formats carry the same bytes
+// for values without a native binary form. PostgreSQL's text format for bytea is
+// \x followed by hex, which lets libpq/pgx decode a BLOB column (OID 17)
+// losslessly.
+func textValueString(value interface{}) string {
 	if value == nil {
 		return ""
 	}
-	// PostgreSQL's text format for bytea is \x followed by hex. Emitting that
-	// lets libpq/pgx decode a BLOB column (OID 17) losslessly.
 	if b, ok := value.([]byte); ok {
 		return `\x` + hex.EncodeToString(b)
 	}

+ 1 - 1
pkg/pgserver/connection_test.go

@@ -360,7 +360,7 @@ func TestMultiRowResultBuffersRows(t *testing.T) {
 	for i := 0; i < rows; i++ {
 		result.AddRow(int64(i))
 	}
-	if err := c.sendResult(result, &parser.SelectStmt{}); err != nil {
+	if err := c.sendResult(result, &parser.SelectStmt{}, result.ColumnTypes, nil); err != nil {
 		t.Fatal(err)
 	}
 	// sendResult emits RowDescription + rows DataRows + CommandComplete. With

+ 1 - 1
pkg/pgserver/oid_test.go

@@ -76,7 +76,7 @@ func TestSendResultEmptyProjectionSendsRowDescription(t *testing.T) {
 	result := executor.NewResult("SELECT")
 	result.AddColumnWithType("id", "BIGINT")
 	result.AddColumnWithType("created_at", "DATETIME")
-	if err := c.sendResult(result, &parser.SelectStmt{}); err != nil {
+	if err := c.sendResult(result, &parser.SelectStmt{}, result.ColumnTypes, nil); err != nil {
 		t.Fatal(err)
 	}
 	if err := c.sendReadyForQuery(); err != nil {

+ 355 - 0
pkg/pgserver/pgx_binary_results_test.go

@@ -0,0 +1,355 @@
+package pgserver
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"testing"
+
+	"github.com/jackc/pgx/v5"
+	"github.com/jackc/pgx/v5/pgconn"
+	"github.com/jackc/pgx/v5/pgtype"
+)
+
+// preparePGXBinaryResults creates the table the binary-result tests scan.
+func preparePGXBinaryResults(t *testing.T, conn *pgx.Conn) {
+	t.Helper()
+	ctx := context.Background()
+	for _, sql := range []string{
+		`CREATE TABLE b (id INTEGER PRIMARY KEY, small SMALLINT, big BIGINT, flag BOOLEAN, ratio REAL, score DOUBLE,
+			name TEXT, data BLOB, doc JSONB, tags TEXT[], nums INTEGER[], longs BIGINT[], flags BOOLEAN[], amounts NUMERIC[])`,
+		`INSERT INTO b VALUES (1, 7, 9000000000, 1, 1.5, 2.25, 'alpha', X'00ff', '{"a": 1}',
+			'{x,"y z"}', '{1,2}', '{9000000000}', '{t,f}', '{1.10,12345678901234567890}')`,
+		`INSERT INTO b VALUES (2, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL,
+			'{}', '{NULL,3}', '{NULL,-1}', '{f,NULL}', '{NULL,2}')`,
+		`CREATE TABLE stamps (id INTEGER PRIMARY KEY, created_at TIMESTAMP)`,
+		`INSERT INTO stamps VALUES (1, '2024-01-02 03:04:05')`,
+	} {
+		if _, err := conn.Exec(ctx, sql); err != nil {
+			t.Fatalf("%s: %v", sql, err)
+		}
+	}
+}
+
+// pgx asks for per-column binary results with QueryResultFormats. The default
+// QueryExecMode still prepares the statement and binds the requested formats,
+// so this is the path a pgx application uses when it wants binary results.
+func TestPGXBinaryResultScalars(t *testing.T) {
+	conn := connectPGX(t)
+	preparePGXBinaryResults(t, conn)
+	ctx := context.Background()
+
+	rows, err := conn.Query(ctx,
+		`SELECT id, small, big, flag, ratio, score, name FROM b ORDER BY id`,
+		pgx.QueryResultFormats{1})
+	if err != nil {
+		t.Fatalf("query: %v", err)
+	}
+	defer rows.Close()
+	for i, field := range rows.FieldDescriptions() {
+		if field.Format != 1 {
+			t.Fatalf("field %d format = %d, want binary", i, field.Format)
+		}
+	}
+	var seen int
+	for rows.Next() {
+		var (
+			id    int32
+			small *int16
+			big   *int64
+			flag  *bool
+			ratio *float32
+			score *float64
+			name  *string
+		)
+		if err := rows.Scan(&id, &small, &big, &flag, &ratio, &score, &name); err != nil {
+			t.Fatalf("scan row %d: %v", seen, err)
+		}
+		switch seen {
+		case 0:
+			if id != 1 || small == nil || *small != 7 {
+				t.Fatalf("row 1 id/small = %d/%v", id, small)
+			}
+			if big == nil || *big != 9000000000 {
+				t.Fatalf("row 1 big = %v", big)
+			}
+			if flag == nil || !*flag || ratio == nil || *ratio != 1.5 || score == nil || *score != 2.25 {
+				t.Fatalf("row 1 flag/ratio/score = %v/%v/%v", flag, ratio, score)
+			}
+			if name == nil || *name != "alpha" {
+				t.Fatalf("row 1 name = %v", name)
+			}
+		case 1:
+			if id != 2 {
+				t.Fatalf("row 2 id = %d", id)
+			}
+			if small != nil || big != nil || flag != nil || ratio != nil || score != nil || name != nil {
+				t.Fatalf("row 2 NULL columns = %v %v %v %v %v %v", small, big, flag, ratio, score, name)
+			}
+		}
+		seen++
+	}
+	if err := rows.Err(); err != nil {
+		t.Fatalf("rows: %v", err)
+	}
+	if seen != 2 {
+		t.Fatalf("got %d rows, want 2", seen)
+	}
+}
+
+func TestPGXBinaryResultByteaAndJSONB(t *testing.T) {
+	conn := connectPGX(t)
+	preparePGXBinaryResults(t, conn)
+	ctx := context.Background()
+
+	var data []byte
+	var doc map[string]any
+	if err := conn.QueryRow(ctx, `SELECT data, doc FROM b WHERE id = 1`, pgx.QueryResultFormats{1}).
+		Scan(&data, &doc); err != nil {
+		t.Fatalf("scan bytea/jsonb: %v", err)
+	}
+	if len(data) != 2 || data[0] != 0x00 || data[1] != 0xff {
+		t.Fatalf("bytea = %v, want [0 255]", data)
+	}
+	if len(doc) != 1 || doc["a"] != float64(1) {
+		t.Fatalf("jsonb = %v, want {\"a\": 1}", doc)
+	}
+
+	var raw json.RawMessage
+	if err := conn.QueryRow(ctx, `SELECT doc FROM b WHERE id = 1`, pgx.QueryResultFormats{1}).
+		Scan(&raw); err != nil {
+		t.Fatalf("scan jsonb RawMessage: %v", err)
+	}
+	var decoded map[string]any
+	if err := json.Unmarshal(raw, &decoded); err != nil {
+		t.Fatalf("jsonb RawMessage %q: %v", raw, err)
+	}
+	if decoded["a"] != float64(1) {
+		t.Fatalf("jsonb RawMessage = %q", raw)
+	}
+}
+
+func TestPGXBinaryResultArrays(t *testing.T) {
+	conn := connectPGX(t)
+	preparePGXBinaryResults(t, conn)
+	ctx := context.Background()
+
+	var tags []string
+	var nums []int32
+	var longs []int64
+	var flags []bool
+	if err := conn.QueryRow(ctx,
+		`SELECT tags, nums, longs, flags FROM b WHERE id = 1`, pgx.QueryResultFormats{1}).
+		Scan(&tags, &nums, &longs, &flags); err != nil {
+		t.Fatalf("scan arrays: %v", err)
+	}
+	if len(tags) != 2 || tags[0] != "x" || tags[1] != "y z" {
+		t.Fatalf("tags = %q", tags)
+	}
+	if len(nums) != 2 || nums[0] != 1 || nums[1] != 2 {
+		t.Fatalf("nums = %v", nums)
+	}
+	if len(longs) != 1 || longs[0] != 9000000000 {
+		t.Fatalf("longs = %v", longs)
+	}
+	if len(flags) != 2 || !flags[0] || flags[1] {
+		t.Fatalf("flags = %v", flags)
+	}
+
+	// An empty array round-trips as an empty, non-nil slice.
+	var empty []string
+	if err := conn.QueryRow(ctx, `SELECT tags FROM b WHERE id = 2`, pgx.QueryResultFormats{1}).
+		Scan(&empty); err != nil {
+		t.Fatalf("scan empty array: %v", err)
+	}
+	if empty == nil || len(empty) != 0 {
+		t.Fatalf("empty array = %v (nil=%v)", empty, empty == nil)
+	}
+
+	// NULL elements decode into pointer elements, distinct from zero values.
+	var elements []*int32
+	if err := conn.QueryRow(ctx, `SELECT nums FROM b WHERE id = 2`, pgx.QueryResultFormats{1}).
+		Scan(&elements); err != nil {
+		t.Fatalf("scan NULL array elements: %v", err)
+	}
+	if len(elements) != 2 || elements[0] != nil || elements[1] == nil || *elements[1] != 3 {
+		t.Fatalf("NULL array elements = %v", elements)
+	}
+
+}
+
+func TestPGXBinaryResultNumericArray(t *testing.T) {
+	conn := connectPGX(t)
+	preparePGXBinaryResults(t, conn)
+	ctx := context.Background()
+
+	var amounts []pgtype.Numeric
+	if err := conn.QueryRow(ctx, `SELECT amounts FROM b WHERE id = 1`, pgx.QueryResultFormats{1}).
+		Scan(&amounts); err != nil {
+		t.Fatalf("scan numeric array: %v", err)
+	}
+	if len(amounts) != 2 {
+		t.Fatalf("amounts = %v, want 2 values", amounts)
+	}
+	want := []string{"1.10", "12345678901234567890"}
+	for i, amount := range amounts {
+		value, err := amount.Value()
+		if err != nil {
+			t.Fatalf("numeric %d value: %v", i, err)
+		}
+		if value != want[i] {
+			t.Fatalf("numeric %d = %v, want %q", i, value, want[i])
+		}
+	}
+
+	// NULL numeric elements stay NULL.
+	var second []pgtype.Numeric
+	if err := conn.QueryRow(ctx, `SELECT amounts FROM b WHERE id = 2`, pgx.QueryResultFormats{1}).
+		Scan(&second); err != nil {
+		t.Fatalf("scan numeric array with NULL: %v", err)
+	}
+	if len(second) != 2 || second[0].Valid || !second[1].Valid {
+		t.Fatalf("numeric array with NULL = %+v", second)
+	}
+}
+
+// A per-column list mixes text and binary in one result, and pgx decodes each
+// column with the format its RowDescription reports.
+func TestPGXBinaryResultMixedFormats(t *testing.T) {
+	conn := connectPGX(t)
+	preparePGXBinaryResults(t, conn)
+	ctx := context.Background()
+
+	rows, err := conn.Query(ctx, `SELECT name, id, flag FROM b WHERE id = 1`,
+		pgx.QueryResultFormats{0, 1, 0})
+	if err != nil {
+		t.Fatalf("query: %v", err)
+	}
+	defer rows.Close()
+	formats := []int16{}
+	for _, field := range rows.FieldDescriptions() {
+		formats = append(formats, field.Format)
+	}
+	if !equalInt16(formats, []int16{0, 1, 0}) {
+		t.Fatalf("formats = %v, want [0 1 0]", formats)
+	}
+	var name string
+	var id int32
+	var flag string
+	if !rows.Next() {
+		t.Fatalf("no row: %v", rows.Err())
+	}
+	if err := rows.Scan(&name, &id, &flag); err != nil {
+		t.Fatalf("scan mixed formats: %v", err)
+	}
+	if name != "alpha" || id != 1 || flag != "1" {
+		t.Fatalf("mixed row = %q %d %q", name, id, flag)
+	}
+	if err := rows.Err(); err != nil {
+		t.Fatalf("rows: %v", err)
+	}
+}
+
+// A binary request for a type without a binary encoder reports SQLSTATE 0A000
+// and the connection keeps working for the next query.
+func TestPGXBinaryResultUnsupportedOID(t *testing.T) {
+	conn := connectPGX(t)
+	preparePGXBinaryResults(t, conn)
+	ctx := context.Background()
+
+	rows, err := conn.Query(ctx, `SELECT created_at FROM stamps`, pgx.QueryResultFormats{1})
+	if err == nil {
+		// Execute errors are delivered through the rows, not the Query call.
+		rows.Close()
+		err = rows.Err()
+	}
+	if err == nil {
+		t.Fatal("binary timestamptz query succeeded, want 0A000")
+	}
+	var pgErr *pgconn.PgError
+	if !errors.As(err, &pgErr) || pgErr.Code != ErrCodeFeatureNotSupported {
+		t.Fatalf("error = %v, want SQLSTATE %s", err, ErrCodeFeatureNotSupported)
+	}
+
+	var created string
+	if err := conn.QueryRow(ctx, `SELECT created_at FROM stamps`).Scan(&created); err != nil {
+		t.Fatalf("text query after 0A000: %v", err)
+	}
+	if created != "2024-01-02 03:04:05" {
+		t.Fatalf("text timestamp = %q", created)
+	}
+}
+
+// Without an explicit result format pgx keeps getting text results.
+func TestPGXTextResultsUnchanged(t *testing.T) {
+	conn := connectPGX(t)
+	preparePGXBinaryResults(t, conn)
+	ctx := context.Background()
+
+	rows, err := conn.Query(ctx, `SELECT id, name FROM b WHERE id = 1`)
+	if err != nil {
+		t.Fatalf("query: %v", err)
+	}
+	defer rows.Close()
+	for i, field := range rows.FieldDescriptions() {
+		if field.Format != 0 {
+			t.Fatalf("field %d format = %d, want text", i, field.Format)
+		}
+	}
+	var id int32
+	var name string
+	if !rows.Next() {
+		t.Fatal("no row")
+	}
+	if err := rows.Scan(&id, &name); err != nil {
+		t.Fatalf("scan: %v", err)
+	}
+	if id != 1 || name != "alpha" {
+		t.Fatalf("text row = %d %q", id, name)
+	}
+}
+
+// RETURNING rows honor the requested binary formats like any other result.
+func TestPGXBinaryResultReturning(t *testing.T) {
+	conn := connectPGX(t)
+	preparePGXBinaryResults(t, conn)
+	ctx := context.Background()
+
+	var id int32
+	if err := conn.QueryRow(ctx,
+		`INSERT INTO stamps (id, created_at) VALUES (2, '2024-02-03 04:05:06') RETURNING id`,
+		pgx.QueryResultFormats{1}).Scan(&id); err != nil {
+		t.Fatalf("binary RETURNING: %v", err)
+	}
+	if id != 2 {
+		t.Fatalf("RETURNING id = %d, want 2", id)
+	}
+}
+
+// Binary results coexist with the parameter path from #40: a bound parameter is
+// decoded to its text literal and the projected value is returned in binary.
+func TestPGXBinaryResultWithParameters(t *testing.T) {
+	conn := connectPGX(t)
+	preparePGXBinaryResults(t, conn)
+	ctx := context.Background()
+
+	var id int32
+	var name string
+	if err := conn.QueryRow(ctx,
+		`SELECT id, name FROM b WHERE id = $1`, pgx.QueryResultFormats{1}, int32(1)).
+		Scan(&id, &name); err != nil {
+		t.Fatalf("binary query with parameter: %v", err)
+	}
+	if id != 1 || name != "alpha" {
+		t.Fatalf("row = %d %q", id, name)
+	}
+
+	var doubled int64
+	if err := conn.QueryRow(ctx,
+		`SELECT $1::BIGINT * 2`, pgx.QueryResultFormats{1}, int64(21)).Scan(&doubled); err != nil {
+		t.Fatalf("binary result of a parameter expression: %v", err)
+	}
+	if doubled != 42 {
+		t.Fatalf("doubled = %d, want 42", doubled)
+	}
+}

+ 18 - 0
pkg/pgserver/protocol.go

@@ -266,6 +266,24 @@ func (mb *MessageBuilder) WriteBytes(b []byte) {
 	mb.data = append(mb.data, b...)
 }
 
+// AppendBinary appends a length-prefixed payload produced by encode. encode
+// appends the payload directly into the message buffer and returns the
+// extended slice, so a binary value is encoded once without an intermediate
+// buffer; a length of -1 is never produced here because NULL values are written
+// by the caller.
+func (mb *MessageBuilder) AppendBinary(encode func(dst []byte) ([]byte, error)) error {
+	start := len(mb.data)
+	mb.data = append(mb.data, 0, 0, 0, 0)
+	encoded, err := encode(mb.data)
+	if err != nil {
+		mb.data = mb.data[:start]
+		return err
+	}
+	mb.data = encoded
+	binary.BigEndian.PutUint32(mb.data[start:start+4], uint32(len(mb.data)-start-4))
+	return nil
+}
+
 // Bytes returns the built message data
 func (mb *MessageBuilder) Bytes() []byte {
 	return mb.data

+ 0 - 2
pkg/pgserver/protocol_guards_test.go

@@ -39,8 +39,6 @@ func TestUnsupportedExtendedRequestsFailBeforeSideEffects(t *testing.T) {
 		message *Message
 		code    string
 	}{
-		{"binary_results", &Message{Type: MsgBind, Data: guardBind(nil, []int16{1})}, ErrCodeFeatureNotSupported},
-		{"per_column_results", &Message{Type: MsgBind, Data: guardBind(nil, []int16{0, 0})}, ErrCodeFeatureNotSupported},
 		{"invalid_result_format", &Message{Type: MsgBind, Data: guardBind(nil, []int16{2})}, ErrCodeProtocolViolation},
 		{"invalid_parameter_format", &Message{Type: MsgBind, Data: guardBind([]int16{2}, nil)}, ErrCodeProtocolViolation},
 		{"parameter_format_count", &Message{Type: MsgBind, Data: guardBind([]int16{0, 0}, nil)}, ErrCodeProtocolViolation},

+ 523 - 0
pkg/pgserver/result_binary.go

@@ -0,0 +1,523 @@
+package pgserver
+
+import (
+	"encoding/binary"
+	"errors"
+	"fmt"
+	"math"
+	"strconv"
+	"strings"
+
+	"github.com/danfragoso/pizzasql-next/pkg/executor"
+)
+
+// Binary result encoding.
+//
+// DataRow values are normally sent in the PostgreSQL text format. A client that
+// asks for a binary result column (Bind result format code 1) gets the same
+// value in PostgreSQL's binary representation for the OID RowDescription
+// reports. Values are appended directly into the DataRow buffer, and a value
+// that cannot be represented fails the row before it is written.
+//
+// The supported OIDs are the ones this engine reports (see getOIDForType) plus
+// the array element types that appear in binary array headers. Types without a
+// binary representation here (timestamptz 1184, date 1082) are rejected at
+// execution time with 0A000 rather than being sent as text.
+
+// canEncodeBinaryResult reports whether the wire layer can encode a result
+// column of this OID in the binary format.
+func canEncodeBinaryResult(oid int32) bool {
+	switch oid {
+	case 16, // bool
+		17,   // bytea
+		20,   // int8
+		21,   // int2
+		23,   // int4
+		25,   // text
+		700,  // float4
+		701,  // float8
+		1043, // varchar
+		1700, // numeric
+		3802: // jsonb
+		return true
+	}
+	_, ok := binaryResultElementOID(oid)
+	return ok
+}
+
+// binaryResultElementOID maps a one-dimensional array OID to the element OID its
+// binary envelope must carry.
+func binaryResultElementOID(arrayOID int32) (int32, bool) {
+	switch arrayOID {
+	case 1000: // bool[]
+		return 16, true
+	case 1005: // int2[]
+		return 21, true
+	case 1007: // int4[]
+		return 23, true
+	case 1009: // text[]
+		return 25, true
+	case 1015: // varchar[]
+		return 1043, true
+	case 1016: // int8[]
+		return 20, true
+	case 1021: // float4[]
+		return 700, true
+	case 1022: // float8[]
+		return 701, true
+	case 1231: // numeric[]
+		return 1700, true
+	default:
+		return 0, false
+	}
+}
+
+// binaryResultEncodeError reports a value the requested binary format cannot
+// represent, such as text left in an INTEGER column by SQLite affinity. The
+// connection reports it as invalid text input and sends no further rows.
+type binaryResultEncodeError struct {
+	err error
+}
+
+func (e *binaryResultEncodeError) Error() string { return e.err.Error() }
+func (e *binaryResultEncodeError) Unwrap() error { return e.err }
+
+func binaryEncodeErrorf(format string, args ...interface{}) error {
+	return &binaryResultEncodeError{err: fmt.Errorf(format, args...)}
+}
+
+// failBinaryEncode reports a value the requested binary format cannot represent.
+// Rows already written stay written (an error discards the rest of the result),
+// but the failing DataRow is never emitted.
+func (c *Connection) failBinaryEncode(err error) error {
+	var encodeErr *binaryResultEncodeError
+	if errors.As(err, &encodeErr) {
+		return c.failExtended(ErrCodeInvalidText, err)
+	}
+	return err
+}
+
+// appendBinaryResultValue appends the PostgreSQL binary representation of value
+// for oid.
+func appendBinaryResultValue(dst []byte, oid int32, value interface{}) ([]byte, error) {
+	if elementOID, ok := binaryResultElementOID(oid); ok {
+		return appendBinaryArray(dst, elementOID, value)
+	}
+	switch oid {
+	case 16: // bool
+		flag, err := binaryBool(value)
+		if err != nil {
+			return dst, err
+		}
+		if flag {
+			return append(dst, 1), nil
+		}
+		return append(dst, 0), nil
+	case 17: // bytea
+		return append(dst, binaryBytes(value)...), nil
+	case 20, 21, 23: // int8, int2, int4
+		width := 8
+		switch oid {
+		case 21:
+			width = 2
+		case 23:
+			width = 4
+		}
+		n, err := binaryInt64(value, oid)
+		if err != nil {
+			return dst, err
+		}
+		switch width {
+		case 2:
+			return append(dst, byte(n>>8), byte(n)), nil
+		case 4:
+			return append(dst, byte(n>>24), byte(n>>16), byte(n>>8), byte(n)), nil
+		default:
+			return appendUint64(dst, uint64(n)), nil
+		}
+	case 25, 1043: // text, varchar
+		return append(dst, textValueString(value)...), nil
+	case 700: // float4
+		f, err := binaryFloat64(value)
+		if err != nil {
+			return dst, err
+		}
+		bits := math.Float32bits(float32(f))
+		return append(dst, byte(bits>>24), byte(bits>>16), byte(bits>>8), byte(bits)), nil
+	case 701: // float8
+		f, err := binaryFloat64(value)
+		if err != nil {
+			return dst, err
+		}
+		return appendUint64(dst, math.Float64bits(f)), nil
+	case 1700: // numeric
+		return appendNumericValue(dst, value)
+	case 3802: // jsonb: version byte plus canonical JSON text
+		dst = append(dst, 1)
+		return append(dst, binaryJSONText(value)...), nil
+	default:
+		return dst, binaryEncodeErrorf("binary result format is not supported for OID %d", oid)
+	}
+}
+
+// appendBinaryArray appends a one-dimensional binary array. The stored value is
+// the canonical PostgreSQL array text, so parsing it yields the same elements
+// the text format carries; NULL elements keep their -1 length and an empty
+// array has no dimension header, as array_recv expects.
+func appendBinaryArray(dst []byte, elementOID int32, value interface{}) ([]byte, error) {
+	text := binaryJSONText(value)
+	elements, err := executor.ParseArrayText(text)
+	if err != nil {
+		return dst, binaryEncodeErrorf("invalid array value %q: %v", text, err)
+	}
+	hasNull := false
+	for _, element := range elements {
+		if element == nil {
+			hasNull = true
+			break
+		}
+	}
+	dimensions := int32(1)
+	if len(elements) == 0 {
+		dimensions = 0
+	}
+	dst = appendUint32(dst, uint32(dimensions))
+	if hasNull {
+		dst = appendUint32(dst, 1)
+	} else {
+		dst = appendUint32(dst, 0)
+	}
+	dst = appendUint32(dst, uint32(elementOID))
+	if dimensions == 0 {
+		return dst, nil
+	}
+	dst = appendUint32(dst, uint32(len(elements)))
+	dst = appendUint32(dst, 1) // lower bound
+	for _, element := range elements {
+		if element == nil {
+			dst = appendUint32(dst, math.MaxUint32) // -1: NULL element
+			continue
+		}
+		start := len(dst)
+		dst = appendUint32(dst, 0)
+		dst, err = appendBinaryResultValue(dst, elementOID, *element)
+		if err != nil {
+			return dst, err
+		}
+		binary.BigEndian.PutUint32(dst[start:start+4], uint32(len(dst)-start-4))
+	}
+	return dst, nil
+}
+
+func appendUint32(dst []byte, n uint32) []byte {
+	return append(dst, byte(n>>24), byte(n>>16), byte(n>>8), byte(n))
+}
+
+func appendUint64(dst []byte, n uint64) []byte {
+	return append(dst, byte(n>>56), byte(n>>48), byte(n>>40), byte(n>>32), byte(n>>24), byte(n>>16), byte(n>>8), byte(n))
+}
+
+// binaryInt64 converts an engine value to an integer result of the given OID.
+// Text is accepted because SQLite affinity can leave a text value in an integer
+// column; a value that does not fit the reported OID is an encoding error
+// instead of a silently truncated one.
+func binaryInt64(value interface{}, oid int32) (int64, error) {
+	var n int64
+	switch v := value.(type) {
+	case int64:
+		n = v
+	case int:
+		n = int64(v)
+	case int32:
+		n = int64(v)
+	case int16:
+		n = int64(v)
+	case int8:
+		n = int64(v)
+	case float64:
+		if math.Trunc(v) != v || v < math.MinInt64 || v > math.MaxInt64 {
+			return 0, binaryEncodeErrorf("value %v is not an integer", v)
+		}
+		n = int64(v)
+	case string:
+		parsed, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
+		if err != nil {
+			return 0, binaryEncodeErrorf("value %q is not an integer", v)
+		}
+		n = parsed
+	case []byte:
+		parsed, err := strconv.ParseInt(strings.TrimSpace(string(v)), 10, 64)
+		if err != nil {
+			return 0, binaryEncodeErrorf("value %q is not an integer", v)
+		}
+		n = parsed
+	default:
+		return 0, binaryEncodeErrorf("value of type %T is not an integer", value)
+	}
+	switch oid {
+	case 21:
+		if n < math.MinInt16 || n > math.MaxInt16 {
+			return 0, binaryEncodeErrorf("value %d does not fit int2", n)
+		}
+	case 23:
+		if n < math.MinInt32 || n > math.MaxInt32 {
+			return 0, binaryEncodeErrorf("value %d does not fit int4", n)
+		}
+	}
+	return n, nil
+}
+
+// binaryFloat64 converts an engine value to a floating-point result.
+func binaryFloat64(value interface{}) (float64, error) {
+	switch v := value.(type) {
+	case float64:
+		return v, nil
+	case float32:
+		return float64(v), nil
+	case int64:
+		return float64(v), nil
+	case int:
+		return float64(v), nil
+	case int32:
+		return float64(v), nil
+	case int16:
+		return float64(v), nil
+	case string:
+		parsed, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
+		if err != nil {
+			return 0, binaryEncodeErrorf("value %q is not a floating-point number", v)
+		}
+		return parsed, nil
+	case []byte:
+		parsed, err := strconv.ParseFloat(strings.TrimSpace(string(v)), 64)
+		if err != nil {
+			return 0, binaryEncodeErrorf("value %q is not a floating-point number", v)
+		}
+		return parsed, nil
+	default:
+		return 0, binaryEncodeErrorf("value of type %T is not a floating-point number", value)
+	}
+}
+
+// binaryBool converts an engine value to a boolean result. Boolean columns hold
+// int64 0/1 from storage, bool from expressions, and text when the inserted
+// literal did not convert.
+func binaryBool(value interface{}) (bool, error) {
+	switch v := value.(type) {
+	case bool:
+		return v, nil
+	case int64:
+		return v != 0, nil
+	case int:
+		return v != 0, nil
+	case int32:
+		return v != 0, nil
+	case int16:
+		return v != 0, nil
+	case float64:
+		return v != 0, nil
+	case string:
+		return boolText(v)
+	case []byte:
+		return boolText(string(v))
+	default:
+		return false, binaryEncodeErrorf("value of type %T is not a boolean", value)
+	}
+}
+
+func boolText(text string) (bool, error) {
+	switch strings.ToLower(strings.TrimSpace(text)) {
+	case "t", "true", "1", "y", "yes", "on":
+		return true, nil
+	case "f", "false", "0", "n", "no", "off":
+		return false, nil
+	default:
+		return false, binaryEncodeErrorf("value %q is not a boolean", text)
+	}
+}
+
+// binaryBytes returns the bytes a binary bytea result carries. A non-[]byte
+// value uses the same text the bytea text format would send, which is the
+// escape-format interpretation of the value's spelling.
+func binaryBytes(value interface{}) []byte {
+	if b, ok := value.([]byte); ok {
+		return b
+	}
+	return []byte(textValueString(value))
+}
+
+// binaryJSONText returns the JSON text a jsonb result carries.
+func binaryJSONText(value interface{}) string {
+	switch v := value.(type) {
+	case string:
+		return v
+	case []byte:
+		return string(v)
+	default:
+		return textValueString(value)
+	}
+}
+
+// appendNumericValue appends a PostgreSQL binary numeric value. Numeric values
+// are stored as exact decimal text, so they are encoded digit by digit into
+// base-10000 groups; converting through float64 would round digits above 2^53
+// and lose the display scale (1.10 would become 1.1).
+func appendNumericValue(dst []byte, value interface{}) ([]byte, error) {
+	var text string
+	switch v := value.(type) {
+	case string:
+		text = v
+	case []byte:
+		text = string(v)
+	case int64:
+		text = strconv.FormatInt(v, 10)
+	case int:
+		text = strconv.FormatInt(int64(v), 10)
+	case float64:
+		text = strconv.FormatFloat(v, 'g', -1, 64)
+	default:
+		return dst, binaryEncodeErrorf("value of type %T is not numeric", value)
+	}
+	return appendNumericText(dst, text)
+}
+
+// appendNumericText encodes exact decimal text as a PostgreSQL numeric: ndigits,
+// weight, sign, dscale, then base-10000 groups. Exponents are applied to the
+// decimal point, trailing fraction zeros only affect dscale, and leading zero
+// groups are dropped while adjusting weight.
+func appendNumericText(dst []byte, text string) ([]byte, error) {
+	invalid := func() ([]byte, error) {
+		return dst, binaryEncodeErrorf("invalid numeric value %q", text)
+	}
+	s := strings.TrimSpace(text)
+	if s == "" {
+		return invalid()
+	}
+	negative := false
+	if s[0] == '+' || s[0] == '-' {
+		negative = s[0] == '-'
+		s = s[1:]
+	}
+	if s == "" {
+		return invalid()
+	}
+	exponent := 0
+	if index := strings.IndexAny(s, "eE"); index >= 0 {
+		parsed, err := strconv.Atoi(s[index+1:])
+		if err != nil {
+			return invalid()
+		}
+		if parsed > math.MaxInt16*4 || parsed < math.MinInt16*4 {
+			return dst, binaryEncodeErrorf("numeric exponent %d is out of range", parsed)
+		}
+		exponent = parsed
+		s = s[:index]
+	}
+	point := strings.IndexByte(s, '.')
+	var digits string
+	if point < 0 {
+		point = len(s)
+		digits = s
+	} else {
+		digits = s[:point] + s[point+1:]
+	}
+	if digits == "" {
+		return invalid()
+	}
+	for i := 0; i < len(digits); i++ {
+		if digits[i] < '0' || digits[i] > '9' {
+			return invalid()
+		}
+	}
+	point += exponent
+	if point > math.MaxInt16*4 || point < math.MinInt16*4 {
+		return dst, binaryEncodeErrorf("numeric exponent is out of range in %q", text)
+	}
+
+	// The display scale is the number of fraction digits after the exponent,
+	// including zeros this encoder drops from the base-10000 groups (1.10 keeps
+	// dscale 2).
+	dscale := len(digits) - point
+	if dscale < 0 {
+		dscale = 0
+	}
+	if dscale > math.MaxInt16 {
+		return dst, binaryEncodeErrorf("numeric scale %d is out of range", dscale)
+	}
+
+	integerDigits := ""
+	if point >= len(digits) {
+		integerDigits = digits + strings.Repeat("0", point-len(digits))
+	} else if point > 0 {
+		integerDigits = digits[:point]
+	}
+	fractionDigits := ""
+	if point < 0 {
+		fractionDigits = strings.Repeat("0", -point) + digits
+	} else if point < len(digits) {
+		fractionDigits = digits[point:]
+	}
+
+	var groups []uint16
+	weight := 0
+	if trimmed := strings.TrimLeft(integerDigits, "0"); trimmed != "" {
+		padding := (4 - len(trimmed)%4) % 4
+		padded := strings.Repeat("0", padding) + trimmed
+		weight = len(padded)/4 - 1
+		for i := 0; i < len(padded); i += 4 {
+			groups = append(groups, uint16(decimalGroup(padded[i:i+4])))
+		}
+	}
+	if trimmed := strings.TrimRight(fractionDigits, "0"); trimmed != "" {
+		padding := (4 - len(trimmed)%4) % 4
+		padded := trimmed + strings.Repeat("0", padding)
+		if len(groups) == 0 {
+			weight = -1
+		}
+		for i := 0; i < len(padded); i += 4 {
+			groups = append(groups, uint16(decimalGroup(padded[i:i+4])))
+		}
+	}
+	leading := 0
+	for leading < len(groups) && groups[leading] == 0 {
+		leading++
+	}
+	if leading == len(groups) {
+		groups = nil
+		weight = 0
+	} else {
+		groups = groups[leading:]
+		weight -= leading
+	}
+	for len(groups) > 0 && groups[len(groups)-1] == 0 {
+		groups = groups[:len(groups)-1]
+	}
+	if weight < math.MinInt16 || weight > math.MaxInt16 {
+		return dst, binaryEncodeErrorf("numeric weight %d is out of range", weight)
+	}
+	if len(groups) > math.MaxUint16 {
+		return dst, binaryEncodeErrorf("numeric has %d base-10000 groups, out of range", len(groups))
+	}
+
+	dst = append(dst, byte(len(groups)>>8), byte(len(groups)))
+	dst = append(dst, byte(weight>>8), byte(weight))
+	sign := 0
+	if negative && len(groups) > 0 {
+		sign = 0x4000
+	}
+	dst = append(dst, byte(sign>>8), byte(sign))
+	dst = append(dst, byte(dscale>>8), byte(dscale))
+	for _, group := range groups {
+		dst = append(dst, byte(group>>8), byte(group))
+	}
+	return dst, nil
+}
+
+// decimalGroup parses four decimal digits, which are always in 0000-9999.
+func decimalGroup(digits string) int {
+	n := 0
+	for i := 0; i < len(digits); i++ {
+		n = n*10 + int(digits[i]-'0')
+	}
+	return n
+}

+ 892 - 0
pkg/pgserver/result_binary_test.go

@@ -0,0 +1,892 @@
+package pgserver
+
+import (
+	"bufio"
+	"bytes"
+	"encoding/binary"
+	"math"
+	"testing"
+)
+
+// extendedResultConnection returns a connection whose protocol output is
+// captured in memory, so Bind/Execute responses can be decoded.
+func extendedResultConnection(t *testing.T) (*Connection, *countingConn) {
+	t.Helper()
+	c, _ := newDataConnection(t)
+	conn := &countingConn{}
+	c.conn = conn
+	c.writer = bufio.NewWriter(conn)
+	return c, conn
+}
+
+func parseMessage(name, sql string) *Message {
+	mb := NewMessageBuilder()
+	mb.WriteString(name)
+	mb.WriteString(sql)
+	mb.WriteInt16(0) // no parameter OIDs
+	return &Message{Type: MsgParse, Data: mb.Bytes()}
+}
+
+// bindResultMessage binds a portal with text parameters and the given result
+// format list.
+func bindResultMessage(portal, statement string, params []string, resultFormats []int16) *Message {
+	mb := NewMessageBuilder()
+	mb.WriteString(portal)
+	mb.WriteString(statement)
+	mb.WriteInt16(0) // all-text parameter formats
+	mb.WriteInt16(int16(len(params)))
+	for _, param := range params {
+		mb.WriteInt32(int32(len(param)))
+		mb.WriteBytes([]byte(param))
+	}
+	mb.WriteInt16(int16(len(resultFormats)))
+	for _, format := range resultFormats {
+		mb.WriteInt16(format)
+	}
+	return &Message{Type: MsgBind, Data: mb.Bytes()}
+}
+
+func executeMessage(portal string) *Message {
+	mb := NewMessageBuilder()
+	mb.WriteString(portal)
+	mb.WriteInt32(0)
+	return &Message{Type: MsgExecute, Data: mb.Bytes()}
+}
+
+func sendExtended(t *testing.T, c *Connection, messages ...*Message) {
+	t.Helper()
+	for _, message := range messages {
+		if err := c.handleMessage(message); err != nil {
+			t.Fatalf("handle %c: %v", message.Type, err)
+		}
+	}
+}
+
+// dataRowRawValues splits a DataRow into its raw field bytes; nil means SQL
+// NULL.
+func dataRowRawValues(t *testing.T, msg *Message) [][]byte {
+	t.Helper()
+	if msg.Type != MsgDataRow {
+		t.Fatalf("message type = %c, want DataRow", msg.Type)
+	}
+	if len(msg.Data) < 2 {
+		t.Fatal("short DataRow")
+	}
+	count := int(binary.BigEndian.Uint16(msg.Data[:2]))
+	pos := 2
+	values := make([][]byte, 0, count)
+	for i := 0; i < count; i++ {
+		if pos+4 > len(msg.Data) {
+			t.Fatal("short DataRow field length")
+		}
+		length := int32(binary.BigEndian.Uint32(msg.Data[pos : pos+4]))
+		pos += 4
+		if length == -1 {
+			values = append(values, nil)
+			continue
+		}
+		if length < 0 || pos+int(length) > len(msg.Data) {
+			t.Fatalf("field %d length %d out of range", i, length)
+		}
+		values = append(values, msg.Data[pos:pos+int(length)])
+		pos += int(length)
+	}
+	if pos != len(msg.Data) {
+		t.Fatalf("DataRow has %d trailing bytes", len(msg.Data)-pos)
+	}
+	return values
+}
+
+func dataRowsRaw(t *testing.T, messages []*Message) [][][]byte {
+	t.Helper()
+	var rows [][][]byte
+	for _, message := range messages {
+		if message.Type == MsgDataRow {
+			rows = append(rows, dataRowRawValues(t, message))
+		}
+	}
+	return rows
+}
+
+func rowDescriptionFormats(t *testing.T, data []byte) []int16 {
+	t.Helper()
+	if len(data) < 2 {
+		t.Fatal("row description too short")
+	}
+	count := int(binary.BigEndian.Uint16(data[:2]))
+	pos := 2
+	formats := make([]int16, 0, count)
+	for i := 0; i < count; i++ {
+		end := bytes.IndexByte(data[pos:], 0)
+		if end < 0 {
+			t.Fatal("unterminated column name")
+		}
+		pos += end + 1 // name
+		pos += 4       // table OID
+		pos += 2       // column attribute number
+		pos += 4       // type OID
+		pos += 2       // type size
+		pos += 4       // type modifier
+		if pos+2 > len(data) {
+			t.Fatal("truncated row description")
+		}
+		formats = append(formats, int16(binary.BigEndian.Uint16(data[pos:pos+2])))
+		pos += 2
+	}
+	return formats
+}
+
+func messageTypes(messages []*Message) string {
+	var types []byte
+	for _, message := range messages {
+		types = append(types, message.Type)
+	}
+	return string(types)
+}
+
+func prepareBinaryResultTable(t *testing.T, c *Connection) {
+	t.Helper()
+	for _, sql := range []string{
+		`CREATE TABLE t (id INTEGER PRIMARY KEY, flag BOOLEAN, name TEXT, amount REAL, score DOUBLE, doc JSONB, data BLOB, tags TEXT[], nums NUMERIC[], flags BOOLEAN[])`,
+		`INSERT INTO t VALUES (1, 1, 'alpha', 1.5, 2.25, '{"a": 1}', X'00ff', '{a,"b c"}', '{1.10,12345678901234567890}', '{t,f}')`,
+		`INSERT INTO t VALUES (2, 0, NULL, NULL, NULL, NULL, NULL, '{}', '{NULL,2}', '{f,NULL}')`,
+	} {
+		if _, err := c.executor.Execute(parseStmt(t, sql)); err != nil {
+			t.Fatalf("%s: %v", sql, err)
+		}
+	}
+}
+
+const binaryResultSelect = `SELECT id, flag, name, amount, score, doc, data, tags, nums, flags FROM t ORDER BY id`
+
+// An empty Bind result format list keeps every column in text format, and the
+// RowDescription reports format 0 for all of them.
+func TestBinaryResultsAllTextByDefault(t *testing.T) {
+	c, conn := extendedResultConnection(t)
+	prepareBinaryResultTable(t, c)
+	sendExtended(t, c,
+		parseMessage("s", binaryResultSelect),
+		bindResultMessage("p", "s", nil, nil),
+		executeMessage("p"),
+		&Message{Type: MsgSync},
+	)
+	messages := readAllMessages(t, conn.bytes())
+	if msg := findError(messages); msg != nil {
+		t.Fatalf("text result failed: %s", errorCode(msg))
+	}
+	rd := findRowDescription(t, messages)
+	for i, format := range rowDescriptionFormats(t, rd.Data) {
+		if format != 0 {
+			t.Fatalf("column %d format = %d, want text", i, format)
+		}
+	}
+	rows := dataRowsRaw(t, messages)
+	if len(rows) != 2 {
+		t.Fatalf("got %d rows, want 2", len(rows))
+	}
+	if string(rows[0][2]) != "alpha" || string(rows[0][3]) != "1.5" {
+		t.Fatalf("text row = %q", rows[0])
+	}
+}
+
+// A single result format code repeats for every column, so one binary entry
+// makes the whole result binary. Every supported scalar and array type is
+// checked against its exact wire representation.
+func TestBinaryResultsSingleFormatRepeats(t *testing.T) {
+	c, conn := extendedResultConnection(t)
+	prepareBinaryResultTable(t, c)
+	sendExtended(t, c,
+		parseMessage("s", binaryResultSelect),
+		bindResultMessage("p", "s", nil, []int16{1}),
+		executeMessage("p"),
+		&Message{Type: MsgSync},
+	)
+	messages := readAllMessages(t, conn.bytes())
+	if msg := findError(messages); msg != nil {
+		t.Fatalf("binary result failed: %s", errorCode(msg))
+	}
+	rd := findRowDescription(t, messages)
+	formats := rowDescriptionFormats(t, rd.Data)
+	if len(formats) != 10 {
+		t.Fatalf("RowDescription has %d columns, want 10", len(formats))
+	}
+	for i, format := range formats {
+		if format != 1 {
+			t.Fatalf("column %d format = %d, want binary", i, format)
+		}
+	}
+	// The OIDs do not change with the format.
+	oids := rowDescriptionOIDs(t, rd.Data)
+	wantOIDs := []int32{23, 16, 25, 700, 701, 3802, 17, 1009, 1231, 1000}
+	for i, want := range wantOIDs {
+		if oids[i] != want {
+			t.Errorf("column %d OID = %d, want %d", i, oids[i], want)
+		}
+	}
+
+	rows := dataRowsRaw(t, messages)
+	if len(rows) != 2 {
+		t.Fatalf("got %d rows, want 2", len(rows))
+	}
+	row := rows[0]
+	assertBytes(t, "int4", row[0], []byte{0, 0, 0, 1})
+	assertBytes(t, "bool", row[1], []byte{1})
+	assertBytes(t, "text", row[2], []byte("alpha"))
+	assertBytes(t, "float4", row[3], binary.BigEndian.AppendUint32(nil, math.Float32bits(1.5)))
+	assertBytes(t, "float8", row[4], binary.BigEndian.AppendUint64(nil, math.Float64bits(2.25)))
+	if len(row[5]) == 0 || row[5][0] != 1 {
+		t.Fatalf("jsonb value %v does not start with the version byte", row[5])
+	}
+	if !bytes.Contains(row[5][1:], []byte(`"a"`)) {
+		t.Fatalf("jsonb value %q does not carry the JSON text", row[5])
+	}
+	assertBytes(t, "bytea", row[6], []byte{0x00, 0xff})
+
+	// text[]: one dimension, two elements, lower bound 1.
+	assertArrayHeader(t, "text[]", row[7], 25, 2, false)
+	if got := arrayElements(t, row[7]); len(got) != 2 || string(got[0]) != "a" || string(got[1]) != "b c" {
+		t.Fatalf("text[] elements = %q", got)
+	}
+
+	// numeric[]: 1.10 keeps its scale, and the 20-digit value stays exact.
+	assertArrayHeader(t, "numeric[]", row[8], 1700, 2, false)
+	numericElements := arrayElements(t, row[8])
+	assertNumericBinary(t, "1.10", numericElements[0], 2, 0, 2, []uint16{1, 1000})
+	assertNumericBinary(t, "12345678901234567890", numericElements[1], 5, 4, 0, []uint16{1234, 5678, 9012, 3456, 7890})
+
+	// bool[]: t/f.
+	assertArrayHeader(t, "bool[]", row[9], 16, 2, false)
+	if got := arrayElements(t, row[9]); len(got) != 2 || got[0][0] != 1 || got[1][0] != 0 {
+		t.Fatalf("bool[] elements = %v", got)
+	}
+
+	// Row 2 exercises NULL columns, an empty array, and NULL array elements.
+	second := rows[1]
+	assertBytes(t, "int4 second row", second[0], []byte{0, 0, 0, 2})
+	assertBytes(t, "bool second row", second[1], []byte{0})
+	for _, index := range []int{2, 3, 4, 5, 6} {
+		if second[index] != nil {
+			t.Errorf("column %d = %v, want NULL", index, second[index])
+		}
+	}
+	// An empty array has no dimension header.
+	assertBytes(t, "empty text[]", second[7], []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 25})
+	assertArrayHeader(t, "numeric[] with NULL", second[8], 1700, 2, true)
+	numericSecond := arrayElements(t, second[8])
+	if numericSecond[0] != nil {
+		t.Fatalf("numeric[] first element = %v, want NULL", numericSecond[0])
+	}
+	assertNumericBinary(t, "2", numericSecond[1], 1, 0, 0, []uint16{2})
+	assertArrayHeader(t, "bool[] with NULL", second[9], 16, 2, true)
+	boolSecond := arrayElements(t, second[9])
+	if boolSecond[0] == nil || boolSecond[0][0] != 0 || boolSecond[1] != nil {
+		t.Fatalf("bool[] elements = %v, want [f NULL]", boolSecond)
+	}
+}
+
+// Mixed lists choose text and binary per column in one result.
+func TestBinaryResultsMixedPerColumnFormats(t *testing.T) {
+	c, conn := extendedResultConnection(t)
+	prepareBinaryResultTable(t, c)
+	// id text, flag binary, name text, amount binary, score text, doc binary,
+	// data text, tags binary, nums text, flags binary.
+	formats := []int16{0, 1, 0, 1, 0, 1, 0, 1, 0, 1}
+	sendExtended(t, c,
+		parseMessage("s", binaryResultSelect),
+		bindResultMessage("p", "s", nil, formats),
+		executeMessage("p"),
+		&Message{Type: MsgSync},
+	)
+	messages := readAllMessages(t, conn.bytes())
+	if msg := findError(messages); msg != nil {
+		t.Fatalf("mixed result failed: %s", errorCode(msg))
+	}
+	rd := findRowDescription(t, messages)
+	if got := rowDescriptionFormats(t, rd.Data); !equalInt16(got, formats) {
+		t.Fatalf("RowDescription formats = %v, want %v", got, formats)
+	}
+	rows := dataRowsRaw(t, messages)
+	if len(rows) != 2 {
+		t.Fatalf("got %d rows, want 2", len(rows))
+	}
+	row := rows[0]
+	// Text columns carry their text spelling.
+	assertBytes(t, "text id", row[0], []byte("1"))
+	assertBytes(t, "text name", row[2], []byte("alpha"))
+	assertBytes(t, "text score", row[4], []byte("2.25"))
+	assertBytes(t, "text data", row[6], []byte(`\x00ff`))
+	assertBytes(t, "text nums", row[8], []byte("{1.10,12345678901234567890}"))
+	// Binary columns carry their binary representation.
+	assertBytes(t, "binary flag", row[1], []byte{1})
+	assertBytes(t, "binary amount", row[3], binary.BigEndian.AppendUint32(nil, math.Float32bits(1.5)))
+	if len(row[5]) == 0 || row[5][0] != 1 {
+		t.Fatalf("jsonb value %v does not start with the version byte", row[5])
+	}
+	assertArrayHeader(t, "binary tags", row[7], 25, 2, false)
+	assertArrayHeader(t, "binary flags", row[9], 16, 2, false)
+}
+
+// Formats belong to the portal, not the statement: two portals over one
+// prepared statement can use different formats, and re-binding a portal
+// replaces its list.
+func TestBinaryResultsArePerPortal(t *testing.T) {
+	c, conn := extendedResultConnection(t)
+	prepareBinaryResultTable(t, c)
+	sql := `SELECT id, flag FROM t ORDER BY id`
+	sendExtended(t, c,
+		parseMessage("s", sql),
+		bindResultMessage("text_portal", "s", nil, nil),
+		bindResultMessage("binary_portal", "s", nil, []int16{1}),
+		executeMessage("text_portal"),
+		executeMessage("binary_portal"),
+		bindResultMessage("text_portal", "s", nil, []int16{0}),
+		executeMessage("text_portal"),
+		&Message{Type: MsgSync},
+	)
+	messages := readAllMessages(t, conn.bytes())
+	if msg := findError(messages); msg != nil {
+		t.Fatalf("per-portal result failed: %s", errorCode(msg))
+	}
+	var descriptions [][]int16
+	var rows [][][]byte
+	for _, message := range messages {
+		switch message.Type {
+		case MsgRowDescription:
+			descriptions = append(descriptions, rowDescriptionFormats(t, message.Data))
+		case MsgDataRow:
+			rows = append(rows, dataRowRawValues(t, message))
+		}
+	}
+	if len(descriptions) != 3 {
+		t.Fatalf("got %d RowDescriptions, want 3", len(descriptions))
+	}
+	if !equalInt16(descriptions[0], []int16{0, 0}) {
+		t.Fatalf("text portal formats = %v", descriptions[0])
+	}
+	if !equalInt16(descriptions[1], []int16{1, 1}) {
+		t.Fatalf("binary portal formats = %v", descriptions[1])
+	}
+	if !equalInt16(descriptions[2], []int16{0, 0}) {
+		t.Fatalf("re-bound text portal formats = %v", descriptions[2])
+	}
+	// The two text portals put "1" and "1" in the first row; the binary portal
+	// puts four bytes.
+	if len(rows) != 6 {
+		t.Fatalf("got %d rows, want 6", len(rows))
+	}
+	if string(rows[0][0]) != "1" || string(rows[1][0]) != "2" {
+		t.Fatalf("text portal rows = %q %q", rows[0][0], rows[1][0])
+	}
+	assertBytes(t, "binary portal id", rows[2][0], []byte{0, 0, 0, 1})
+	if string(rows[4][0]) != "1" || string(rows[5][0]) != "2" {
+		t.Fatalf("re-bound text portal rows = %q %q", rows[4][0], rows[5][0])
+	}
+}
+
+// A format list whose length is neither one nor the column count is a protocol
+// violation, and no RowDescription or DataRow is sent before the error.
+func TestBinaryResultsFormatCountMismatch(t *testing.T) {
+	c, conn := extendedResultConnection(t)
+	prepareBinaryResultTable(t, c)
+	sendExtended(t, c,
+		parseMessage("s", `SELECT id, flag FROM t`),
+		bindResultMessage("p", "s", nil, []int16{0, 1, 0}),
+		executeMessage("p"),
+		&Message{Type: MsgSync},
+	)
+	messages := readAllMessages(t, conn.bytes())
+	if got := messageTypes(messages); got != "12"+string(MsgErrorResponse)+string(MsgReadyForQuery) {
+		t.Fatalf("messages = %q", got)
+	}
+	if code := errorCode(findError(messages)); code != ErrCodeProtocolViolation {
+		t.Fatalf("SQLSTATE = %s, want %s", code, ErrCodeProtocolViolation)
+	}
+	if c.extendedFailed {
+		t.Fatal("Sync did not clear the extended-failure state")
+	}
+
+	// The connection is reusable after the rejected cycle.
+	conn.reset()
+	sendExtended(t, c,
+		parseMessage("s2", `SELECT id FROM t ORDER BY id`),
+		bindResultMessage("p2", "s2", nil, []int16{1}),
+		executeMessage("p2"),
+		&Message{Type: MsgSync},
+	)
+	messages = readAllMessages(t, conn.bytes())
+	if msg := findError(messages); msg != nil {
+		t.Fatalf("query after format error failed: %s", errorCode(msg))
+	}
+	rows := dataRowsRaw(t, messages)
+	if len(rows) != 2 || !bytes.Equal(rows[0][0], []byte{0, 0, 0, 1}) {
+		t.Fatalf("rows after format error = %v", rows)
+	}
+}
+
+// A binary request for a type the wire layer cannot encode fails with 0A000
+// before any result frame, including when the statement has rows to return.
+func TestBinaryResultsUnsupportedOID(t *testing.T) {
+	c, conn := extendedResultConnection(t)
+	if _, err := c.executor.Execute(parseStmt(t, `CREATE TABLE stamps (id INTEGER PRIMARY KEY, created_at TIMESTAMP)`)); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := c.executor.Execute(parseStmt(t, `INSERT INTO stamps VALUES (1, '2024-01-02 03:04:05')`)); err != nil {
+		t.Fatal(err)
+	}
+	sendExtended(t, c,
+		parseMessage("s", `SELECT id, created_at FROM stamps`),
+		bindResultMessage("p", "s", nil, []int16{1}),
+		executeMessage("p"),
+		&Message{Type: MsgSync},
+	)
+	messages := readAllMessages(t, conn.bytes())
+	if got := messageTypes(messages); got != "12"+string(MsgErrorResponse)+string(MsgReadyForQuery) {
+		t.Fatalf("messages = %q", got)
+	}
+	if code := errorCode(findError(messages)); code != ErrCodeFeatureNotSupported {
+		t.Fatalf("SQLSTATE = %s, want %s", code, ErrCodeFeatureNotSupported)
+	}
+	// Text format for the same statement still works.
+	conn.reset()
+	sendExtended(t, c,
+		parseMessage("s2", `SELECT created_at FROM stamps`),
+		bindResultMessage("p2", "s2", nil, nil),
+		executeMessage("p2"),
+		&Message{Type: MsgSync},
+	)
+	messages = readAllMessages(t, conn.bytes())
+	if msg := findError(messages); msg != nil {
+		t.Fatalf("text query after unsupported binary failed: %s", errorCode(msg))
+	}
+	if rows := dataRowsRaw(t, messages); len(rows) != 1 || string(rows[0][0]) != "2024-01-02 03:04:05" {
+		t.Fatalf("text rows = %q", rows)
+	}
+}
+
+// A rejected format inside an explicit transaction block marks the transaction
+// failed, as any statement error does, and no rows are emitted.
+func TestBinaryResultsErrorFailsTransaction(t *testing.T) {
+	c, conn := extendedResultConnection(t)
+	if _, err := c.executor.Execute(parseStmt(t, `CREATE TABLE t (id INTEGER PRIMARY KEY)`)); err != nil {
+		t.Fatal(err)
+	}
+	sendExtended(t, c,
+		&Message{Type: MsgQuery, Data: []byte("BEGIN\x00")},
+		parseMessage("s", `SELECT id FROM t`),
+		bindResultMessage("p", "s", nil, []int16{0, 1}),
+		executeMessage("p"),
+		&Message{Type: MsgSync},
+	)
+	messages := readAllMessages(t, conn.bytes())
+	ready := messages[len(messages)-1]
+	if ready.Type != MsgReadyForQuery || ready.Data[0] != TxStatusFailed {
+		t.Fatalf("transaction status = %q, want failed", messageTypes(messages))
+	}
+	if code := errorCode(findError(messages)); code != ErrCodeProtocolViolation {
+		t.Fatalf("SQLSTATE = %s, want %s", code, ErrCodeProtocolViolation)
+	}
+	// While failed, the next statement is refused with 25P02.
+	conn.reset()
+	sendExtended(t, c,
+		parseMessage("s2", `SELECT id FROM t`),
+		bindResultMessage("p2", "s2", nil, nil),
+		executeMessage("p2"),
+		&Message{Type: MsgSync},
+	)
+	messages = readAllMessages(t, conn.bytes())
+	if code := errorCode(findError(messages)); code != ErrCodeTransactionAborted {
+		t.Fatalf("SQLSTATE = %s, want %s", code, ErrCodeTransactionAborted)
+	}
+}
+
+// appendNumericText encodes exact decimal text into base-10000 groups, keeping
+// the display scale and rejecting values that are not decimal numbers.
+func TestAppendNumericTextEncoding(t *testing.T) {
+	cases := []struct {
+		text    string
+		ndigits uint16
+		weight  int16
+		sign    uint16
+		dscale  uint16
+		digits  []uint16
+	}{
+		{"0", 0, 0, 0, 0, nil},
+		{"1.10", 2, 0, 0, 2, []uint16{1, 1000}},
+		{"-2.5", 2, 0, 0x4000, 1, []uint16{2, 5000}},
+		{"12345678901234567890", 5, 4, 0, 0, []uint16{1234, 5678, 9012, 3456, 7890}},
+		{"1e5", 1, 1, 0, 0, []uint16{10}},
+		{"0.00001", 1, -2, 0, 5, []uint16{1000}},
+		{"-0.000", 0, 0, 0, 3, nil},
+	}
+	for _, tc := range cases {
+		t.Run(tc.text, func(t *testing.T) {
+			encoded, err := appendNumericText(nil, tc.text)
+			if err != nil {
+				t.Fatalf("appendNumericText(%q): %v", tc.text, err)
+			}
+			assertNumericBinary(t, tc.text, encoded, tc.ndigits, tc.weight, tc.dscale, tc.digits, tc.sign)
+		})
+	}
+	for _, text := range []string{"", "abc", "1.2.3", "NaN", "1e999999"} {
+		if _, err := appendNumericText(nil, text); err == nil {
+			t.Errorf("appendNumericText(%q) succeeded, want an error", text)
+		}
+	}
+}
+
+// assertNumericBinary checks a binary numeric payload.
+func assertNumericBinary(t *testing.T, label string, data []byte, ndigits uint16, weight int16, dscale uint16, digits []uint16, sign ...uint16) {
+	t.Helper()
+	wantSign := uint16(0)
+	if len(sign) > 0 {
+		wantSign = sign[0]
+	}
+	if len(data) != 8+2*len(digits) {
+		t.Fatalf("%s numeric length = %d, want %d (%v)", label, len(data), 8+2*len(digits), data)
+	}
+	if got := binary.BigEndian.Uint16(data[0:2]); got != ndigits {
+		t.Errorf("%s ndigits = %d, want %d", label, got, ndigits)
+	}
+	if got := int16(binary.BigEndian.Uint16(data[2:4])); got != weight {
+		t.Errorf("%s weight = %d, want %d", label, got, weight)
+	}
+	if got := binary.BigEndian.Uint16(data[4:6]); got != wantSign {
+		t.Errorf("%s sign = %#x, want %#x", label, got, wantSign)
+	}
+	if got := binary.BigEndian.Uint16(data[6:8]); got != dscale {
+		t.Errorf("%s dscale = %d, want %d", label, got, dscale)
+	}
+	for i, want := range digits {
+		if got := binary.BigEndian.Uint16(data[8+2*i : 10+2*i]); got != want {
+			t.Errorf("%s digit %d = %d, want %d", label, i, got, want)
+		}
+	}
+}
+
+func assertArrayHeader(t *testing.T, label string, data []byte, elementOID int32, count int, hasNull bool) {
+	t.Helper()
+	if len(data) < 12 {
+		t.Fatalf("%s array is %d bytes, want a header", label, len(data))
+	}
+	if ndim := int32(binary.BigEndian.Uint32(data[0:4])); ndim != 1 {
+		t.Fatalf("%s ndim = %d, want 1", label, ndim)
+	}
+	wantNull := uint32(0)
+	if hasNull {
+		wantNull = 1
+	}
+	if got := binary.BigEndian.Uint32(data[4:8]); got != wantNull {
+		t.Errorf("%s hasnull = %d, want %d", label, got, wantNull)
+	}
+	if got := int32(binary.BigEndian.Uint32(data[8:12])); got != elementOID {
+		t.Errorf("%s element OID = %d, want %d", label, got, elementOID)
+	}
+	if len(data) < 20 {
+		t.Fatalf("%s array is %d bytes, want a dimension header", label, len(data))
+	}
+	if got := int32(binary.BigEndian.Uint32(data[12:16])); got != int32(count) {
+		t.Errorf("%s dimension length = %d, want %d", label, got, count)
+	}
+	if got := int32(binary.BigEndian.Uint32(data[16:20])); got != 1 {
+		t.Errorf("%s lower bound = %d, want 1", label, got)
+	}
+}
+
+// arrayElements splits the element payloads of a one-dimensional binary array.
+func arrayElements(t *testing.T, data []byte) [][]byte {
+	t.Helper()
+	if len(data) < 20 {
+		t.Fatalf("array is %d bytes, want a dimension header", len(data))
+	}
+	count := int(binary.BigEndian.Uint32(data[12:16]))
+	pos := 20
+	elements := make([][]byte, 0, count)
+	for i := 0; i < count; i++ {
+		if pos+4 > len(data) {
+			t.Fatalf("array element %d is truncated", i)
+		}
+		length := int32(binary.BigEndian.Uint32(data[pos : pos+4]))
+		pos += 4
+		if length == -1 {
+			elements = append(elements, nil)
+			continue
+		}
+		if length < 0 || pos+int(length) > len(data) {
+			t.Fatalf("array element %d length %d out of range", i, length)
+		}
+		elements = append(elements, data[pos:pos+int(length)])
+		pos += int(length)
+	}
+	if pos != len(data) {
+		t.Fatalf("array has %d trailing bytes", len(data)-pos)
+	}
+	return elements
+}
+
+func assertBytes(t *testing.T, label string, got, want []byte) {
+	t.Helper()
+	if !bytes.Equal(got, want) {
+		t.Fatalf("%s = %q (%v), want %q (%v)", label, got, got, want, want)
+	}
+}
+
+func equalInt16(left, right []int16) bool {
+	if len(left) != len(right) {
+		return false
+	}
+	for i := range left {
+		if left[i] != right[i] {
+			return false
+		}
+	}
+	return true
+}
+
+// Describe never exposes the portal's bound formats: statement and portal
+// Describe both return NoData, and the formats travel with the RowDescription
+// emitted at Execute.
+func TestBinaryResultsDescribeStaysNoData(t *testing.T) {
+	c, conn := extendedResultConnection(t)
+	prepareBinaryResultTable(t, c)
+	sendExtended(t, c,
+		parseMessage("s", `SELECT id, flag FROM t`),
+		bindResultMessage("p", "s", nil, []int16{1}),
+		&Message{Type: MsgDescribe, Data: []byte{'S', 's', 0}},
+		&Message{Type: MsgDescribe, Data: []byte{'P', 'p', 0}},
+		&Message{Type: MsgSync},
+	)
+	messages := readAllMessages(t, conn.bytes())
+	if got := messageTypes(messages); got != "12tnnZ" {
+		t.Fatalf("messages = %q, want ParseComplete/BindComplete/ParameterDescription/NoData/NoData/ReadyForQuery", got)
+	}
+}
+
+// Simple-query results keep text format; only a Bind result format list can
+// request binary columns.
+func TestSimpleQueryResultsStayText(t *testing.T) {
+	c, conn := extendedResultConnection(t)
+	prepareBinaryResultTable(t, c)
+	messages := runSimple(t, c, conn, `SELECT id, flag, tags FROM t ORDER BY id`)
+	if msg := findError(messages); msg != nil {
+		t.Fatalf("simple query failed: %s", errorCode(msg))
+	}
+	rd := findRowDescription(t, messages)
+	for i, format := range rowDescriptionFormats(t, rd.Data) {
+		if format != 0 {
+			t.Fatalf("column %d format = %d, want text", i, format)
+		}
+	}
+	rows := dataRowsRaw(t, messages)
+	if len(rows) != 2 || string(rows[0][2]) != `{a,"b c"}` {
+		t.Fatalf("simple rows = %q", rows)
+	}
+}
+
+// VARCHAR arrays report OID 1015 and carry element OID 1043.
+func TestBinaryResultsVarcharArray(t *testing.T) {
+	c, conn := extendedResultConnection(t)
+	if _, err := c.executor.Execute(parseStmt(t, `CREATE TABLE v (id INTEGER PRIMARY KEY, names VARCHAR(10)[])`)); err != nil {
+		t.Fatal(err)
+	}
+	if _, err := c.executor.Execute(parseStmt(t, `INSERT INTO v VALUES (1, '{a,"b c"}')`)); err != nil {
+		t.Fatal(err)
+	}
+	sendExtended(t, c,
+		parseMessage("s", `SELECT names FROM v`),
+		bindResultMessage("p", "s", nil, []int16{1}),
+		executeMessage("p"),
+		&Message{Type: MsgSync},
+	)
+	messages := readAllMessages(t, conn.bytes())
+	if msg := findError(messages); msg != nil {
+		t.Fatalf("varchar[] result failed: %s", errorCode(msg))
+	}
+	rd := findRowDescription(t, messages)
+	if oid := rowDescriptionOIDs(t, rd.Data)[0]; oid != 1015 {
+		t.Fatalf("varchar[] OID = %d, want 1015", oid)
+	}
+	rows := dataRowsRaw(t, messages)
+	assertArrayHeader(t, "varchar[]", rows[0][0], 1043, 2, false)
+	elements := arrayElements(t, rows[0][0])
+	if len(elements) != 2 || string(elements[0]) != "a" || string(elements[1]) != "b c" {
+		t.Fatalf("varchar[] elements = %q", elements)
+	}
+}
+
+// TestAppendBinaryResultValue covers the encoders that no SQL type reaches
+// directly, such as int2 results and int2 arrays.
+func TestAppendBinaryResultValue(t *testing.T) {
+	cases := []struct {
+		name  string
+		oid   int32
+		value interface{}
+		want  []byte
+	}{
+		{"bool true", 16, bool(true), []byte{1}},
+		{"bool from int64", 16, int64(0), []byte{0}},
+		{"bool from text", 16, "yes", []byte{1}},
+		{"int2", 21, int64(-2), []byte{0xff, 0xfe}},
+		{"int2 from text", 21, "12", []byte{0, 12}},
+		{"int4", 23, int64(1), []byte{0, 0, 0, 1}},
+		{"int8", 20, int64(-1), []byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}},
+		{"float4", 700, float64(1.5), binary.BigEndian.AppendUint32(nil, math.Float32bits(1.5))},
+		{"float8", 701, int64(2), binary.BigEndian.AppendUint64(nil, math.Float64bits(2))},
+		{"text", 25, "alpha", []byte("alpha")},
+		{"varchar", 1043, "beta", []byte("beta")},
+		{"bytea bytes", 17, []byte{0x00, 0xff}, []byte{0x00, 0xff}},
+		{"bytea string", 17, "ab", []byte("ab")},
+		{"numeric text", 1700, "1.10", []byte{0, 2, 0, 0, 0, 0, 0, 2, 0, 1, 0x03, 0xe8}},
+		{"numeric int", 1700, int64(2), []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 2}},
+		{"jsonb", 3802, `{"a": 1}`, []byte(`{"a": 1}`)},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got, err := appendBinaryResultValue(nil, tc.oid, tc.value)
+			if err != nil {
+				t.Fatalf("appendBinaryResultValue(%d, %v): %v", tc.oid, tc.value, err)
+			}
+			want := tc.want
+			if tc.oid == 3802 {
+				want = append([]byte{1}, tc.want...)
+			}
+			assertBytes(t, tc.name, got, want)
+		})
+	}
+
+	for _, tc := range []struct {
+		name  string
+		oid   int32
+		value interface{}
+	}{
+		{"int4 from text", 23, "abc"},
+		{"int2 out of range", 21, int64(70000)},
+		{"int4 out of range", 23, int64(1) << 40},
+		{"bool from text", 16, "maybe"},
+		{"float from text", 701, "abc"},
+		{"unsupported oid", 1184, "2024-01-02 03:04:05"},
+		{"numeric from bool", 1700, true},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			if _, err := appendBinaryResultValue(nil, tc.oid, tc.value); err == nil {
+				t.Fatalf("%s: encoding succeeded, want an error", tc.name)
+			}
+		})
+	}
+}
+
+// TestBinaryResultArrayOIDs covers every array OID in the encoding table,
+// including the int2/varchar forms the engine reports for declared arrays.
+func TestBinaryResultArrayOIDs(t *testing.T) {
+	cases := []struct {
+		name       string
+		arrayOID   int32
+		elementOID int32
+		text       string
+		want       [][]byte
+	}{
+		{"bool[]", 1000, 16, "{t,f}", [][]byte{{1}, {0}}},
+		{"int2[]", 1005, 21, "{1,-2}", [][]byte{{0, 1}, {0xff, 0xfe}}},
+		{"int4[]", 1007, 23, "{1,2}", [][]byte{{0, 0, 0, 1}, {0, 0, 0, 2}}},
+		{"text[]", 1009, 25, `{a,"b c"}`, [][]byte{[]byte("a"), []byte("b c")}},
+		{"varchar[]", 1015, 1043, "{a}", [][]byte{[]byte("a")}},
+		{"int8[]", 1016, 20, "{1}", [][]byte{{0, 0, 0, 0, 0, 0, 0, 1}}},
+		{"float4[]", 1021, 700, "{1.5}", [][]byte{binary.BigEndian.AppendUint32(nil, math.Float32bits(1.5))}},
+		{"float8[]", 1022, 701, "{1.5}", [][]byte{binary.BigEndian.AppendUint64(nil, math.Float64bits(1.5))}},
+		{"numeric[]", 1231, 1700, "{1.10}", [][]byte{{0, 2, 0, 0, 0, 0, 0, 2, 0, 1, 0x03, 0xe8}}},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			got, err := appendBinaryResultValue(nil, tc.arrayOID, tc.text)
+			if err != nil {
+				t.Fatalf("encode %s: %v", tc.name, err)
+			}
+			assertArrayHeader(t, tc.name, got, tc.elementOID, len(tc.want), false)
+			elements := arrayElements(t, got)
+			if len(elements) != len(tc.want) {
+				t.Fatalf("%s elements = %d, want %d", tc.name, len(elements), len(tc.want))
+			}
+			for i := range tc.want {
+				assertBytes(t, tc.name+" element", elements[i], tc.want[i])
+			}
+		})
+	}
+
+	// Unsupported array OIDs are not encodable.
+	for _, oid := range []int32{1001 /* bytea[] */, 1185 /* timestamptz[] */} {
+		if canEncodeBinaryResult(oid) {
+			t.Errorf("canEncodeBinaryResult(%d) = true", oid)
+		}
+	}
+	for _, oid := range []int32{16, 17, 20, 21, 23, 25, 700, 701, 1043, 1700, 3802, 1000, 1005, 1007, 1009, 1015, 1016, 1021, 1022, 1231} {
+		if !canEncodeBinaryResult(oid) {
+			t.Errorf("canEncodeBinaryResult(%d) = false", oid)
+		}
+	}
+	for _, oid := range []int32{1082, 1184} {
+		if canEncodeBinaryResult(oid) {
+			t.Errorf("canEncodeBinaryResult(%d) = true", oid)
+		}
+	}
+}
+
+// A statement that writes and returns rows executes before its Bind result
+// formats are resolved, because the row metadata only exists at Execute. The
+// rejected binary request therefore sends no rows but does not undo the write;
+// this ordering is documented in the capability document.
+func TestBinaryResultsRejectAfterDMLExecution(t *testing.T) {
+	c, conn := extendedResultConnection(t)
+	if _, err := c.executor.Execute(parseStmt(t, `CREATE TABLE w (id INTEGER PRIMARY KEY, created_at TIMESTAMP)`)); err != nil {
+		t.Fatal(err)
+	}
+	sendExtended(t, c,
+		parseMessage("s", `INSERT INTO w VALUES (1, '2024-01-02 03:04:05') RETURNING created_at`),
+		bindResultMessage("p", "s", nil, []int16{1}),
+		executeMessage("p"),
+		&Message{Type: MsgSync},
+	)
+	messages := readAllMessages(t, conn.bytes())
+	if got := messageTypes(messages); got != "12"+string(MsgErrorResponse)+string(MsgReadyForQuery) {
+		t.Fatalf("messages = %q, want no RowDescription or DataRow", got)
+	}
+	if code := errorCode(findError(messages)); code != ErrCodeFeatureNotSupported {
+		t.Fatalf("SQLSTATE = %s, want %s", code, ErrCodeFeatureNotSupported)
+	}
+	result, err := c.executor.Execute(parseStmt(t, `SELECT COUNT(*) FROM w`))
+	if err != nil {
+		t.Fatal(err)
+	}
+	if result.Rows[0][0] != int64(1) {
+		t.Fatalf("write was rolled back: %v", result.Rows)
+	}
+}
+
+// Catalog emulation results use the same per-portal formats as executor
+// results: their text and boolean columns encode as binary.
+func TestBinaryResultsCatalogQuery(t *testing.T) {
+	c, conn := extendedResultConnection(t)
+	if _, err := c.executor.Execute(parseStmt(t, `CREATE TABLE t (id INTEGER PRIMARY KEY)`)); err != nil {
+		t.Fatal(err)
+	}
+	sendExtended(t, c,
+		parseMessage("s", `SELECT tablename, hasindexes FROM pg_tables`),
+		bindResultMessage("p", "s", nil, []int16{1}),
+		executeMessage("p"),
+		&Message{Type: MsgSync},
+	)
+	messages := readAllMessages(t, conn.bytes())
+	if msg := findError(messages); msg != nil {
+		t.Fatalf("binary catalog query failed: %s", errorCode(msg))
+	}
+	rd := findRowDescription(t, messages)
+	if got := rowDescriptionFormats(t, rd.Data); !equalInt16(got, []int16{1, 1}) {
+		t.Fatalf("catalog formats = %v", got)
+	}
+	rows := dataRowsRaw(t, messages)
+	if len(rows) != 1 || string(rows[0][0]) != "t" {
+		t.Fatalf("catalog rows = %q", rows)
+	}
+	// hasindexes is a binary boolean.
+	if len(rows[0][1]) != 1 || rows[0][1][0] != 0 {
+		t.Fatalf("hasindexes = %v, want false", rows[0][1])
+	}
+}

+ 93 - 0
pkg/pgserver/result_formats.go

@@ -0,0 +1,93 @@
+package pgserver
+
+import (
+	"errors"
+	"fmt"
+)
+
+// resultFormatError is a Bind result-format rejection with the SQLSTATE the
+// client must receive.
+type resultFormatError struct {
+	code    string
+	message string
+}
+
+func (e *resultFormatError) Error() string { return e.message }
+
+// resolveResultFormats expands a portal's Bind result format list to one format
+// code per result column and rejects binary requests the wire layer cannot
+// encode.
+//
+// The PostgreSQL Bind rules are: an empty list means text for every column, a
+// single entry applies to every column, and any other list length must equal the
+// number of result columns. The result that is resolved here is the one this
+// server emits at Execute; column metadata only exists then because Describe
+// returns NoData. Formats are stored per portal, so two portals bound to the
+// same statement can use different formats.
+//
+// An all-text result returns nil, which keeps the text row path allocation free.
+func (c *Connection) resolveResultFormats(bindFormats []int16, columnTypes []string, columnCount int) ([]int16, error) {
+	if len(bindFormats) == 0 {
+		return nil, nil
+	}
+	if len(bindFormats) > 1 && len(bindFormats) != columnCount {
+		return nil, &resultFormatError{
+			code:    ErrCodeProtocolViolation,
+			message: fmt.Sprintf("bind message has %d result formats but the query returns %d columns", len(bindFormats), columnCount),
+		}
+	}
+	formats := make([]int16, columnCount)
+	if len(bindFormats) == 1 {
+		for i := range formats {
+			formats[i] = bindFormats[0]
+		}
+	} else {
+		copy(formats, bindFormats)
+	}
+	allText := true
+	for i, format := range formats {
+		switch format {
+		case 0:
+			continue
+		case 1:
+			allText = false
+		default:
+			return nil, &resultFormatError{
+				code:    ErrCodeProtocolViolation,
+				message: fmt.Sprintf("invalid Bind result format code %d", format),
+			}
+		}
+		oid := c.getOIDForType(typeNameAt(columnTypes, i))
+		if !canEncodeBinaryResult(oid) {
+			return nil, &resultFormatError{
+				code:    ErrCodeFeatureNotSupported,
+				message: fmt.Sprintf("binary result format is not supported for column %d (%s, OID %d)", i+1, typeNameAt(columnTypes, i), oid),
+			}
+		}
+	}
+	if allText {
+		return nil, nil
+	}
+	return formats, nil
+}
+
+// failResultFormat reports a result-format error through failExtended so the
+// connection sends an ErrorResponse, discards the rest of the cycle until Sync,
+// and marks an explicit transaction block as failed.
+func (c *Connection) failResultFormat(err error) error {
+	code := ErrCodeProtocolViolation
+	var formatErr *resultFormatError
+	if errors.As(err, &formatErr) {
+		code = formatErr.code
+	}
+	return c.failExtended(code, err)
+}
+
+// typeNameAt returns the declared type name of a result column, or "" when the
+// result carries no metadata for it (the wire layer reports "" as TEXT).
+func typeNameAt(columnTypes []string, index int) string {
+	if index < 0 || index >= len(columnTypes) {
+		return ""
+	}
+	return columnTypes[index]
+}