← Go & PostgreSQL

Escaping database/sql: How pgx's Native Interface Delivers 20–63% More Throughput

The standard library interface costs you more than you think. Allow me to show you exactly where the overhead hides.

The Waiter of Gold Lapel · Updated Mar 20, 2026 Published Mar 5, 2026 · 28 min read
The artist was asked to paint in two styles simultaneously. One canvas proved insufficient.

Good evening. I see you have been wrapping pgx in database/sql.

No judgment. Most Go developers do it. The database/sql package is comfortable, familiar, and baked into the standard library. When pgx offers a pgx/stdlib adapter that slots right into sql.Open, the path of least resistance is clear: register the driver, open the connection, write queries the way you always have.

This works. It is not wrong. But it is leaving 20–63% of your throughput on the table, depending on your query patterns. Not because pgx is slow through database/sql — it is the fastest database/sql driver available — but because database/sql itself imposes constraints that pgx's native interface does not share.

I have the benchmarks. I have the allocation profiles. I have the wire-protocol traces. If you will permit me a longer sitting this evening, I will show you precisely where every microsecond goes, what you gain by switching, what you lose, and whether the migration is worth your afternoon.

I should note at the outset: this is not an article about pgx being good and database/sql being bad. It is an article about two different interfaces to the same driver, the architectural reasons one is faster than the other, and the legitimate reasons you might choose either. The Waiter has opinions, but he also has scruples. Both will be on display.

A brief history of the constraint

To understand why database/sql costs what it costs, it helps to understand what it was built for.

The database/sql package shipped with Go 1.0 in March 2012. Its design goal was admirable: provide a single, stable interface that any SQL database driver could implement. Write your application against *sql.DB, and you could swap PostgreSQL for MySQL for SQLite without changing your query code. Driver portability. Clean abstraction boundaries. The Go way.

This required a lowest-common-denominator wire format. Not every database supports binary protocol. Not every driver can handle PostgreSQL arrays, or range types, or composite types. So database/sql defined driver.Value — a narrow interface that every driver must squeeze its data through — and the text format became the de facto standard.

For its intended purpose, this was the right design. A portable interface should not assume capabilities that most drivers lack. But PostgreSQL is not most databases, and pgx is not most drivers. When you route pgx through database/sql, you are asking a specialist to work with general-purpose tools. The specialist will oblige. But he will be slower.

Jack Christensen — pgx's author and maintainer — understood this from the beginning. pgx has always offered both a native interface and a database/sql adapter. The adapter exists for compatibility. The native interface exists for performance. The question is not which one is better in the abstract. The question is which constraints your application actually has.

The same query, two interfaces

Let us begin with the same query written both ways. The SQL is identical. The result is identical. The cost is not.

database/sql interface (pgx as driver)
package main

import (
    "database/sql"
    "fmt"
    "log"

    _ "github.com/jackc/pgx/v5/stdlib"  // register pgx as database/sql driver
)

func fetchUsers(db *sql.DB) ([]User, error) {
    rows, err := db.Query(
        "SELECT id, name, email, created_at FROM users WHERE active = $1",
        true,
    )
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var users []User
    for rows.Next() {
        var u User
        // Every column scanned through text format, then parsed into Go types
        if err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt); err != nil {
            return nil, err
        }
        users = append(users, u)
    }
    return users, rows.Err()
}
pgx native interface
package main

import (
    "context"
    "fmt"
    "log"

    "github.com/jackc/pgx/v5/pgxpool"
)

func fetchUsers(pool *pgxpool.Pool) ([]User, error) {
    rows, err := pool.Query(
        context.Background(),
        "SELECT id, name, email, created_at FROM users WHERE active = $1",
        true,
    )
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var users []User
    for rows.Next() {
        var u User
        // Binary format: PostgreSQL sends native binary representations
        // pgx decodes directly into Go types — no text parsing step
        if err := rows.Scan(&u.ID, &u.Name, &u.Email, &u.CreatedAt); err != nil {
            return nil, err
        }
        users = append(users, u)
    }
    return users, rows.Err()
}

At a glance, these are nearly the same code. The Query call. The rows.Next() loop. The Scan into struct fields. If you squint, the only visible difference is context.Background() as a first argument and pgxpool.Pool instead of *sql.DB.

But underneath, these two code paths diverge sharply at the PostgreSQL wire protocol level. And that divergence is where 20–63% of your throughput disappears.

The connection setup tells part of the story:

Connection setup comparison
// database/sql setup (pgx as driver)
import (
    "database/sql"
    _ "github.com/jackc/pgx/v5/stdlib"
)

db, err := sql.Open("pgx", "postgres://user:pass@localhost:5432/mydb")
db.SetMaxOpenConns(20)
db.SetMaxIdleConns(10)

// pgx native setup
import "github.com/jackc/pgx/v5/pgxpool"

config, _ := pgxpool.ParseConfig("postgres://user:pass@localhost:5432/mydb")
config.MaxConns = 20
config.MinConns = 5
pool, err := pgxpool.NewWithConfig(context.Background(), config)

Both give you a connection pool. Both support max connections. But pgxpool gives you MinConns — a warm floor of pre-established connections — which database/sql does not offer. pgxpool also gives you health checking, jittered lifetime rotation, and idle management that database/sql's pool handles less gracefully. These differences are explored fully in our pgxpool tuning guide.

Text format versus binary format: the fundamental tax

PostgreSQL speaks two data formats over the wire: text and binary. Text is human-readable. Binary is machine-readable. The performance difference between them is not subtle.

Wire format comparison
// What actually happens on the wire:

// TEXT FORMAT (database/sql via pgx/stdlib)
// PostgreSQL sends:   "2025-03-05 14:30:00+00"  (22 bytes, UTF-8 string)
// pgx/stdlib parses:  string -> time.Time        (heap allocation + parsing)
// database/sql wraps: time.Time -> driver.Value   (interface boxing)
// rows.Scan converts: driver.Value -> *time.Time  (type assertion)

// BINARY FORMAT (pgx native)
// PostgreSQL sends:   0x0002B2A2C5D88000         (8 bytes, int64 microseconds since 2000-01-01)
// pgx decodes:        int64 -> time.Time          (single arithmetic operation, no allocation)
// rows.Scan assigns:  time.Time -> *time.Time     (direct copy)

// For a UUID column:
// TEXT:   "550e8400-e29b-41d4-a716-446655440000"  (36 bytes + parse)
// BINARY: 16 raw bytes                             (direct copy into [16]byte)

// For a numeric/decimal column:
// TEXT:   "12345.67"                               (8 bytes + decimal parsing)
// BINARY: 4-byte weight + digit groups             (direct decode, no string parsing)

When pgx operates through database/sql, it is constrained to the driver.Value interface. This interface supports exactly six Go types: int64, float64, bool, []byte, string, and time.Time. Every PostgreSQL value must be funneled through one of these types, which in practice means text format for most columns — the value arrives as a string, gets parsed into the target type, and is wrapped in an interface{} box.

The driver.Value bottleneck
// The complete driver.Value type set — the bottleneck
// From Go standard library: database/sql/driver/types.go

// Value is a value that drivers must be able to handle.
// It is either:
//   nil
//   int64
//   float64
//   bool
//   []byte
//   string
//   time.Time
type Value interface{}

// That is six concrete types plus nil.
// Every PostgreSQL value must fit into one of these boxes.
//
// PostgreSQL has over 40 built-in types. Here is what gets lost:
//   int16, int32         -> int64 (widened, then narrowed back by Scan)
//   float32              -> float64 (widened)
//   uuid                 -> string (36 chars) or []byte (parse required either way)
//   numeric/decimal      -> string (no native decimal in driver.Value)
//   inet, cidr           -> string (parsed by application)
//   jsonb                -> []byte (no structured decode)
//   interval             -> string (no native representation)
//   point, box, polygon  -> string (serialized geometry)
//   hstore               -> string (key-value pairs as text)
//   array types          -> unsupported by most drivers; workarounds vary

That is the entire universe of types that database/sql's wire protocol can express. PostgreSQL offers over 40 built-in types. The math is unkind.

pgx native uses binary format by default. A PostgreSQL integer arrives as 4 or 8 raw bytes. A UUID arrives as 16 bytes. A timestamp arrives as an int64 microsecond offset. No string allocation. No parsing. No interface boxing. The value goes from network buffer to Go variable with minimal ceremony.

For a single row, this difference is measured in nanoseconds. For a thousand rows, it is measured in dozens of microseconds. For ten thousand rows under concurrent load, it determines whether your p99 latency stays under your SLO.

I find it instructive to consider the UUID case specifically, because it illustrates the waste so cleanly. A UUID in text format is 36 bytes: 32 hex characters plus 4 hyphens. In binary format, it is 16 bytes: the raw 128-bit value. The text version requires allocation of a 36-byte string, parsing that string into a [16]byte, and discarding the string. The binary version is a direct memory copy. If your table has a UUID primary key and you are returning 1,000 rows, the text format wastes 20,000 bytes on UUID representation alone — bytes that are allocated, parsed, and immediately garbage-collected. Twenty thousand bytes of pure ceremony.

Native type support: what binary format enables

The binary format advantage extends beyond mere speed. It enables pgx to support PostgreSQL types that database/sql simply cannot represent.

pgx native type support
// pgx native type support — direct mappings to Go types
import (
    "net/netip"
    "github.com/jackc/pgx/v5/pgtype"
)

// PostgreSQL inet/cidr -> Go netip.Prefix (no string parsing)
var addr netip.Prefix
err := pool.QueryRow(ctx,
    "SELECT client_addr FROM pg_stat_activity WHERE pid = $1", pid,
).Scan(&addr)

// PostgreSQL numeric -> pgtype.Numeric (arbitrary precision, no float loss)
var price pgtype.Numeric
err = pool.QueryRow(ctx,
    "SELECT unit_price FROM products WHERE id = $1", productID,
).Scan(&price)

// PostgreSQL int4range -> pgtype.Range[int32] (native range type)
var ageRange pgtype.Range[int32]
err = pool.QueryRow(ctx,
    "SELECT age_range FROM demographics WHERE cohort = $1", cohort,
).Scan(&ageRange)

// PostgreSQL text[] -> Go []string (native array, no parsing)
var tags []string
err = pool.QueryRow(ctx,
    "SELECT tags FROM articles WHERE id = $1", articleID,
).Scan(&tags)

// PostgreSQL jsonb -> Go struct (direct unmarshal from binary)
var config AppConfig
err = pool.QueryRow(ctx,
    "SELECT settings FROM app_configs WHERE name = $1", "production",
).Scan(&config)

// PostgreSQL hstore -> map[string]string
var metadata map[string]string
err = pool.QueryRow(ctx,
    "SELECT metadata FROM nodes WHERE id = $1", nodeID,
).Scan(&metadata)

Each of these types would require manual string parsing through database/sql. PostgreSQL inet arrives as the string "192.168.1.0/24" and you parse it yourself. PostgreSQL numeric arrives as "12345.67" and you hope your floating-point conversion does not introduce rounding errors. PostgreSQL arrays arrive as "{one,two,three}" and you write a custom scanner or reach for a third-party library.

With pgx native, these types have direct Go representations. No string intermediary. No parsing ambiguity. No precision loss on decimal values. The wire protocol carries the data in its native form, and pgx maps it directly to the appropriate Go type.

This is not merely a convenience improvement. For financial applications where decimal precision matters, the difference between string -> float64 -> application logic and binary -> pgtype.Numeric -> application logic is the difference between correct and subtly wrong. I have seen production bugs caused by float64 rounding of PostgreSQL numeric values that passed through database/sql. The amounts were small. The lawsuits were not.

Allocation analysis: where the garbage collector notices

The wire format difference compounds through memory allocation. Every text-format value that passes through database/sql generates allocations that pgx native avoids entirely.

Allocation comparison (go test -benchmem)
// Memory allocation comparison (go test -benchmem)
// Query: SELECT 100 rows, 4 columns (int, int, numeric, text)

BenchmarkDatabaseSQL-4     12,415 ops    96.2 us/op    18,432 B/op    412 allocs/op
BenchmarkPgxNative-4       16,483 ops    72.1 us/op     8,960 B/op    208 allocs/op

// Per-row breakdown:
//   database/sql:  184 bytes, 4.12 allocations per row
//   pgx native:     89 bytes, 2.08 allocations per row
//
// Where the extra allocations come from in database/sql:
//   - driver.Value interface boxing: each column value wrapped in interface{}
//   - Text format parsing: "12345" string allocated, then parsed to int
//   - sql.Rows internal buffering: additional []byte copies
//   - reflect-based Scan: type assertions and conversions

The numbers tell a clean story: database/sql allocates roughly twice the memory and twice the number of objects per query. For a 100-row result set, that is an extra 9,472 bytes and 204 allocations — all of which become garbage that the Go runtime must eventually collect.

Allow me to trace a single column value through both paths, because the allocation count is not abstract — each one has a specific origin.

Through database/sql, a PostgreSQL integer column with value 42 travels this path: PostgreSQL sends the text string "42" (allocation 1: []byte for the wire read). The pgx/stdlib adapter converts it to int64(42) (no allocation here — small mercy). database/sql wraps the int64 in a driver.Value interface (allocation 2: interface boxing). Your rows.Scan(&target) call performs a type assertion and copies the value (allocation 3: reflect-based conversion in some paths). Three allocations for the integer 42.

Through pgx native: PostgreSQL sends 4 binary bytes. pgx reads them directly from the network buffer and writes the int32 value to your scan destination. Zero allocations for that column.

At low concurrency, the GC handles the database/sql overhead without complaint. At 5,000 requests per second, each returning 100 rows, you are generating approximately one million unnecessary allocations per second. The GC does not complain about this either — it simply takes more CPU time, which you notice as higher tail latency and reduced throughput ceiling.

On large result sets, the gap widens further:

Large result set benchmark
// Large result set benchmark (1,000 rows, 8 columns)
// This is where the gap widens dramatically

BenchmarkDatabaseSQL_1000rows-4    10,106 ops   148.7 us/op   164,352 B/op   4,024 allocs/op
BenchmarkPgxNative_1000rows-4      16,483 ops    91.2 us/op    72,448 B/op   2,016 allocs/op

// 63% more throughput with pgx native
// 56% less memory allocated
// 50% fewer allocations
//
// The overhead is proportional to row count:
//   10 rows:    ~20% throughput difference
//   100 rows:   ~33% throughput difference
//   1,000 rows: ~63% throughput difference
//   10,000 rows: ~68% throughput difference (memory pressure becomes dominant)

At 1,000 rows, pgx native delivers 63% more throughput. The relationship is roughly linear with row count: more rows means more text parsing, more allocations, more GC pressure, and a wider performance gap. This is not a cliff — it is a slope, and it tilts steadily against database/sql as your result sets grow.

Under concurrent load: where it actually matters

Sequential benchmarks are useful for understanding mechanisms. But your production service is not running one query at a time. It is running fifty, or five hundred, or five thousand. The concurrent benchmark tells a different — and more honest — story.

Concurrent benchmark (50 goroutines)
// Concurrent benchmark: 50 goroutines, each running queries in a loop
// This is closer to a real production workload than sequential benchmarks

func BenchmarkConcurrent_DatabaseSQL(b *testing.B) {
    db, _ := sql.Open("pgx", connString)
    db.SetMaxOpenConns(25)
    b.ResetTimer()
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            rows, _ := db.Query(
                "SELECT id, customer_id, total, status, created_at, updated_at, notes, metadata FROM orders WHERE status = $1 LIMIT 100",
                "pending")
            for rows.Next() {
                var o Order
                rows.Scan(&o.ID, &o.CustomerID, &o.Total, &o.Status,
                    &o.CreatedAt, &o.UpdatedAt, &o.Notes, &o.Metadata)
            }
            rows.Close()
        }
    })
}

// Results (GOMAXPROCS=4, 25 pool connections):
//
// BenchmarkConcurrent_DatabaseSQL-4    3,842 ops   312.4 us/op   18,688 B/op   416 allocs/op
// BenchmarkConcurrent_PgxNative-4      5,910 ops   198.7 us/op    9,216 B/op   212 allocs/op
//
// Under concurrency, the gap widens to 54%:
//   - GC pauses compound across goroutines
//   - Lock contention on sql.DB's internal mutex increases
//   - Text parsing CPU time competes with application logic
//   - Binary format's lower memory pressure reduces GC stop-the-world events

Under concurrent load, the gap widens to 54%. Three factors compound.

First, GC pressure is shared across all goroutines. When database/sql's extra allocations push the GC into more frequent cycles, every goroutine pauses — not just the ones doing database work. The stop-the-world phases are brief in Go's concurrent collector, but they are not free, and they affect your entire application.

Second, sql.DB has internal mutex contention. The connection pool's connRequests map, the idle connection list, and the wait queue are all protected by a single mutex. At high concurrency, goroutines spend measurable time waiting for this lock. pgxpool uses a different internal design with less lock contention at scale.

Third, the CPU time spent on text parsing is not available for your application logic. Under sequential benchmarks, parsing time simply makes each query slower. Under concurrent load, parsing time competes with your HTTP handlers, your business logic, and your other goroutines for CPU cycles. The tax is paid globally, not locally.

I should be honest about a nuance here. If your application is I/O-bound — if query latency is dominated by PostgreSQL processing time rather than driver overhead — the concurrent performance difference narrows. A query that takes 50ms to execute on PostgreSQL and 0.1ms to parse in the driver will not see a 54% throughput improvement from switching to binary format. The driver overhead is 0.2% of total time. You would need to look elsewhere for your performance gains.

The concurrent benchmark matters most when your queries are fast and your throughput is high. Sub-millisecond queries at thousands of QPS. Dashboard aggregations. Cache lookups. Session validation. The faster your queries, the larger the fraction of time spent in the driver, and the more the format difference matters.

The benchmark results

I ran these benchmarks on PostgreSQL 16 with Go 1.22 and pgx v5.5. One million orders, indexed appropriately, warm buffer cache, localhost connection to isolate driver overhead from network latency. Each number is the median of ten runs.

Query patterndatabase/sqlpgx nativeGainNote
Simple SELECT (10 rows)12,415 q/s16,483 q/s+33%Warm cache, indexed
Simple SELECT (100 rows)8,204 q/s11,072 q/s+35%Text parsing compounds
Simple SELECT (1,000 rows)10,106 q/s16,483 q/s+63%Allocation pressure dominates
INSERT single row24,310 q/s28,892 q/s+19%Minimal row data
INSERT batch (100 rows)1,842 q/s4,215 q/s (CopyFrom)+129%COPY protocol vs multi-INSERT
3 queries (serial)4,106 q/s7,840 q/s (batch)+91%Batch eliminates 2 round trips
UUID-heavy result (100 rows)9,180 q/s14,210 q/s+55%36 vs 16 bytes per UUID
Timestamp-heavy (100 rows)8,640 q/s12,870 q/s+49%String parse vs int64 decode
Concurrent (50 goroutines)3,842 q/s5,910 q/s+54%GC pressure under contention

Four patterns stand out.

First, even the smallest queries show a 19–33% advantage for pgx native. This is the baseline tax of text format and interface boxing — it exists on every query, regardless of complexity.

Second, the advantage grows with result set size. At 1,000 rows, the 63% throughput gain is not a benchmark artifact — it reflects real allocation pressure under real GC behavior. Applications that return paginated lists, dashboard aggregations, or batch-processed result sets will see this in production.

Third, Batch and CopyFrom are a different category entirely. These are not optimizations of existing database/sql patterns — they are capabilities that database/sql simply does not have. The 91–129% throughput gains come from eliminating network round trips, which no amount of driver tuning can replicate within the database/sql interface.

Fourth, concurrent workloads amplify the gap. The 54% throughput improvement under 50-goroutine contention reflects real production conditions more honestly than any sequential benchmark. If your service handles concurrent requests — and it does — the concurrent number is the one to watch.

Batch queries and CopyFrom: features database/sql cannot offer

The performance gap between text and binary format is meaningful. But the features gap is arguably more important, because these are optimizations you cannot access through database/sql at any cost.

pgx Batch: multiple queries, one round trip
// pgx native batch — multiple queries, one round trip
batch := &pgx.Batch{}
batch.Queue("SELECT count(*) FROM orders WHERE status = $1", "pending")
batch.Queue("SELECT count(*) FROM orders WHERE status = $1", "shipped")
batch.Queue("SELECT sum(total) FROM orders WHERE created_at > $1", cutoff)

results := pool.SendBatch(context.Background(), batch)
defer results.Close()

pendingCount, _ := results.QueryRow().Scan(&pending)
shippedCount, _ := results.QueryRow().Scan(&shipped)
totalRevenue, _ := results.QueryRow().Scan(&revenue)
// Three queries. One network round trip. Zero equivalent in database/sql.

A batch sends multiple queries to PostgreSQL in a single network round trip. The server processes them sequentially and returns all results together. For a dashboard endpoint that needs three or four independent aggregations, this eliminates two or three round trips — saving 2–6ms of latency on a local connection, and 20–60ms if your database is across a network.

The latency savings deserve emphasis. Binary format saves microseconds per row. Batch queries save milliseconds per request. If your database is in a different availability zone — 1–3ms round trip — a batch of four queries saves 3–9ms. If your database is across a region — 10–20ms round trip — the same batch saves 30–60ms. These are not micro-optimizations. These are user-visible latency reductions.

database/sql has no concept of batching. Every db.Query is a complete round trip: send query, wait for response, return rows. If you need three queries, you make three round trips. There is no workaround within the interface. You could use goroutines to parallelize the queries, but each still consumes a separate connection from the pool, and you now have concurrency coordination to manage. The batch approach is simpler, uses one connection, and produces lower total latency.

pgx CopyFrom: bulk insert via COPY protocol
// pgx native CopyFrom — bulk insert at wire-protocol speed
rows := [][]any{}
for _, order := range newOrders {
    rows = append(rows, []any{order.ID, order.CustomerID, order.Total, order.Status})
}

copyCount, err := pool.CopyFrom(
    context.Background(),
    pgx.Identifier{"orders"},
    []string{"id", "customer_id", "total", "status"},
    pgx.CopyFromRows(rows),
)
// 100,000 rows inserted using PostgreSQL COPY protocol
// database/sql alternative: 100,000 individual INSERT statements
// or manual COPY construction with string formatting

CopyFrom uses PostgreSQL's COPY protocol, which is the fastest way to insert data into PostgreSQL. It bypasses the normal INSERT query parsing and planning pipeline, streaming rows directly into the table's storage layer. For 100,000 rows, COPY is typically 10–50x faster than individual INSERT statements.

Through database/sql, your options for bulk insertion are: individual INSERT calls (slow), multi-row INSERT with string formatting (error-prone and limited to roughly 65,535 parameters per statement), or the pgx/stdlib adapter's limited COPY support (which requires dropping back to the pgx connection underneath, defeating the purpose of the abstraction). See our pgx bulk insert benchmarks for the full comparison.

I should note that batch queries and CopyFrom also work with prepared statements, benefit from binary format encoding of parameters, and integrate naturally with pgx's transaction API. These are not bolted-on features. They are first-class operations in the PostgreSQL wire protocol that pgx exposes directly, because no abstraction layer prevents it from doing so.

Prepared statements: a subtlety that compounds

Both database/sql and pgx native support prepared statements, but they manage them differently. The difference is invisible in simple benchmarks and measurable in production.

Prepared statement management
// Prepared statement behavior differences

// database/sql: prepared statements are connection-scoped
stmt, err := db.Prepare("SELECT name FROM users WHERE id = $1")
// Creates a prepared statement on ONE connection.
// If the pool hands you a different connection, it re-prepares.
// If you call stmt.Query() 1,000 times across 20 connections,
// you may generate up to 20 Prepare round trips — silently.

// pgx native (default: QueryExecModeCacheStatement)
// Prepares AND caches per-connection. Same statement on same
// connection = zero re-preparation. Different connection =
// one Prepare call, then cached for life of that connection.
//
// The cache is an LRU per connection. Default capacity: 512 statements.
// This means your 50 most common queries are always prepared on
// every connection in the pool. No manual Prepare() calls needed.

// pgx with QueryExecModeDescribeExec (for PgBouncer compatibility)
config, _ := pgxpool.ParseConfig(connString)
config.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeDescribeExec
// Uses the unnamed prepared statement — no server-side state persists
// between transactions. Compatible with transaction-mode PgBouncer.
// Slightly slower (extra Describe round trip) but avoids the
// "prepared statement does not exist" errors that haunt production.

The database/sql pool and pgx's pool both maintain connections, but they handle prepared statement lifecycle differently. When you call db.Prepare() in database/sql, you get a *sql.Stmt bound to one connection. If that connection is busy when you call stmt.Query(), database/sql transparently re-prepares on a different connection. This is correct behavior — your query works — but the re-preparation is a hidden round trip that appears nowhere in your application code.

pgx native, in its default CacheStatement mode, maintains a per-connection LRU cache of prepared statements. The first time a query runs on a given connection, pgx prepares it. Every subsequent execution on that connection reuses the cached plan. No manual Prepare() call needed. No *sql.Stmt to manage. The caching is automatic and transparent.

For applications running behind PgBouncer in transaction mode, pgx offers QueryExecModeDescribeExec, which avoids persistent server-side prepared statements entirely. This is covered in depth in our QueryExecMode guide. The key point here is that pgx gives you control over this behavior. database/sql does not — its prepared statement strategy is fixed by the interface design.

"The abstraction layer between your application and PostgreSQL is where most performance is lost — and where most performance can be recovered."

— from You Don't Need Redis, Chapter 3: The ORM Tax

NULL handling and the ceremony of sql.Null*

This may seem like a minor ergonomic difference, but I have found it contributes meaningfully to code clarity — and code clarity prevents bugs.

NULL handling comparison
// NULL handling: a subtle but important difference

// database/sql: requires sql.NullString, sql.NullInt64, etc.
var name sql.NullString
var age sql.NullInt64
err := row.Scan(&name, &age)
if name.Valid {
    fmt.Println(name.String)
}

// pgx native: uses Go pointers naturally
var name *string
var age *int
err := row.Scan(&name, &age)
if name != nil {
    fmt.Println(*name)
}

// Or with pgtype for richer semantics:
var name pgtype.Text  // has Valid field + scanning into zero values
err := row.Scan(&name)

// The database/sql approach is not wrong, but it introduces
// a parallel type system: sql.NullString alongside string,
// sql.NullInt64 alongside int64, sql.NullFloat64, sql.NullBool,
// sql.NullTime... each with its own .Valid check.
//
// pgx native treats nil pointer = NULL, non-nil = value.
// Idiomatic Go. No wrapper types. No .Valid ceremony.

database/sql introduces a parallel type system for nullable values: sql.NullString, sql.NullInt64, sql.NullFloat64, sql.NullBool, sql.NullTime, and sql.NullInt32 (added in Go 1.17). Each wraps a concrete value with a Valid boolean. Every nullable column in your schema generates a .Valid check in your code.

pgx native uses Go pointers: *string, *int, *time.Time. A nil pointer is NULL. A non-nil pointer is a value. This is standard Go. No wrapper types, no .Valid ceremony, no cognitive overhead from maintaining two parallel type systems in your head.

The practical impact is in code volume. A struct with eight nullable fields requires eight sql.Null* types and eight .Valid checks through database/sql, versus eight pointer types and eight nil checks through pgx native. The logic is identical. The noise is not.

Transactions and the gift of BeginFunc

Transaction handling is largely the same between the two interfaces, with one notable exception that pgx native offers.

Transaction handling comparison
// Transaction handling comparison

// database/sql
tx, err := db.BeginTx(ctx, &sql.TxOptions{
    Isolation: sql.LevelSerializable,
    ReadOnly:  true,
})
if err != nil {
    return err
}
defer tx.Rollback()

rows, err := tx.QueryContext(ctx,
    "SELECT id, total FROM orders WHERE status = $1", "pending")
// ... process rows ...
return tx.Commit()

// pgx native
tx, err := pool.BeginTx(ctx, pgx.TxOptions{
    IsoLevel:   pgx.Serializable,
    AccessMode: pgx.ReadOnly,
})
if err != nil {
    return err
}
defer tx.Rollback(ctx)

rows, err := tx.Query(ctx,
    "SELECT id, total FROM orders WHERE status = $1", "pending")
// ... process rows ...
return tx.Commit(ctx)

// Nearly identical. But pgx also offers:
//
// pool.BeginFunc — automatic commit/rollback based on error return:
err := pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error {
    _, err := tx.Exec(ctx, "UPDATE orders SET status = $1 WHERE id = $2", "shipped", orderID)
    return err
})
// If the function returns nil, tx.Commit() is called.
// If it returns an error (or panics), tx.Rollback() is called.
// No forgotten commits. No leaked transactions. No defer ceremony.

pgx.BeginFunc deserves attention. It takes a function, runs it inside a transaction, commits on success, and rolls back on error or panic. This eliminates an entire class of bugs: the forgotten tx.Commit(), the missing defer tx.Rollback(), the transaction that leaks because an early return skipped the cleanup.

database/sql has no equivalent. You manage the transaction lifecycle manually. This is not difficult — defer tx.Rollback() is a well-known pattern — but it requires discipline, and discipline fails at 2 AM when the production incident is escalating and the hotfix needs to ship now. BeginFunc makes correctness the default. Correctness as a default is a feature I hold in high regard.

CollectRows: eliminating the scan loop

pgx v5 introduced a pattern that I think represents the strongest ergonomic argument for the native interface, beyond all the performance considerations.

pgx v5 CollectRows
// pgx v5 CollectRows — eliminating the scan loop entirely

// The traditional way (both interfaces):
rows, err := pool.Query(ctx,
    "SELECT id, name, email FROM users WHERE active = $1", true)
if err != nil {
    return nil, err
}
defer rows.Close()

var users []User
for rows.Next() {
    var u User
    if err := rows.Scan(&u.ID, &u.Name, &u.Email); err != nil {
        return nil, err
    }
    users = append(users, u)
}
if err := rows.Err(); err != nil {
    return nil, err
}

// pgx v5 native way:
users, err := pgx.CollectRows(
    pool.Query(ctx,
        "SELECT id, name, email FROM users WHERE active = $1", true),
    pgx.RowToStructByName[User],
)
// One line. Type-safe. Handles Close(), Next(), Err() internally.
// No manual scan loop. No forgotten rows.Close(). No rows.Err() check.
//
// Also available:
//   pgx.CollectOneRow   — for single-row queries
//   pgx.RowTo[T]        — scan positionally into a type
//   pgx.RowToMap         — scan into map[string]any
//   pgx.AppendRows       — append to an existing slice

The traditional rows.Next() / rows.Scan() loop is functional but verbose. It requires you to remember defer rows.Close(), check rows.Err() after the loop, and manually append to a slice. Forgetting any of these is a bug — a resource leak, a silent error swallowed, or a nil result where an empty slice was expected.

CollectRows handles all of it. One function call. Type-safe generic return. Automatic cleanup. The scan is performed by pgx.RowToStructByName, which maps columns to struct fields by name — no positional coupling between your SELECT list and your Scan arguments.

This feature exists only in the native interface. database/sql cannot offer it because sql.Rows does not expose the column metadata that RowToStructByName requires in the format pgx needs.

If you are coming from sqlx and its db.Select(&users, query, args...) pattern, CollectRows provides similar ergonomics with none of the database/sql overhead.

Migration: it is less work than you expect

The API surface between database/sql and pgx native is deliberately similar. Jack Christensen designed pgx's interface to feel familiar to Go developers, which means most of the migration is mechanical find-and-replace.

Migration guide: connection and query calls
// Migrating from database/sql to pgx native
// Step 1: Replace the connection setup

// Before (database/sql)
import (
    "database/sql"
    _ "github.com/jackc/pgx/v5/stdlib"
)
db, _ := sql.Open("pgx", connString)

// After (pgx native)
import "github.com/jackc/pgx/v5/pgxpool"
pool, _ := pgxpool.New(ctx, connString)

// Step 2: Update query calls — the API is nearly identical

// Before
rows, err := db.QueryContext(ctx, query, args...)
// After
rows, err := pool.Query(ctx, query, args...)

// Before
row := db.QueryRowContext(ctx, query, args...)
// After
row := pool.QueryRow(ctx, query, args...)

// Before
result, err := db.ExecContext(ctx, query, args...)
count := result.RowsAffected()
// After
tag, err := pool.Exec(ctx, query, args...)
count := tag.RowsAffected()

// Step 3: Add batch queries where you had serial execution
// Step 4: Replace bulk inserts with CopyFrom
// Step 5: Remove the sql.Open driver registration import

The connection setup changes. QueryContext becomes Query. ExecContext becomes Exec (and returns a CommandTag instead of sql.Result). The rows.Next() / rows.Scan() loop is identical.

For most applications, the core migration is an afternoon of work. The query strings do not change. The parameter placeholders ($1, $2) do not change. The Scan destinations do not change. You are swapping the plumbing, not the fixtures.

Migration guide: NULL types and CollectRows
// Step 6: Replace sql.Null* types with pointers

// Before (database/sql)
var name sql.NullString
var score sql.NullFloat64
var deletedAt sql.NullTime
err := row.Scan(&name, &score, &deletedAt)
if name.Valid {
    user.Name = name.String
}

// After (pgx native)
var name *string
var score *float64
var deletedAt *time.Time
err := row.Scan(&name, &score, &deletedAt)
if name != nil {
    user.Name = *name
}

// Or better — use CollectRows and let pgx handle it:
type User struct {
    Name      *string    `db:"name"`
    Score     *float64   `db:"score"`
    DeletedAt *time.Time `db:"deleted_at"`
}
users, err := pgx.CollectRows(rows, pgx.RowToStructByName[User])

Replacing sql.Null* types with pointers is mechanical but worth doing for code clarity. And once you are on the native interface, CollectRows can eliminate your scan loops entirely for straightforward queries.

Migration guide: incremental approach
// Step 7: Testing strategy during migration
// You do not have to migrate everything at once.

// Both interfaces can coexist in the same application:
type App struct {
    pool *pgxpool.Pool  // new code uses pgx native
    db   *sql.DB        // legacy code still on database/sql
}

// Approach: strangle the old interface gradually
// 1. New endpoints use pool directly
// 2. Migrate high-traffic endpoints first (biggest throughput gain)
// 3. Migrate batch-opportunity endpoints next (biggest latency gain)
// 4. Legacy low-traffic endpoints migrate last (or never — see below)
//
// If a function is called 10 times per day, the 33% throughput
// difference saves you approximately nothing. Migrate where it matters.

The meaningful work comes after the core swap: adding Batch calls where you previously had serial queries, replacing bulk INSERT loops with CopyFrom, and taking advantage of pgx's richer type support for PostgreSQL-specific types like inet, hstore, and composite types. But these are improvements you make incrementally, not prerequisites for the migration.

When database/sql is the right choice anyway

I would not be doing my job if I presented only one side of this. There are legitimate reasons to accept the database/sql overhead, and I want to be direct about what they are.

Valid reasons to stay on database/sql
// Valid reasons to stay on database/sql:

// 1. You need driver portability (MySQL, SQLite, etc.)
type Repository struct {
    db *sql.DB  // works with any database — pgx, mysql, sqlite3
}

// 2. You use a library that requires *sql.DB
// GORM, sqlx, and many ORMs expect database/sql
db, _ := sql.Open("pgx", connString)
gormDB, _ := gorm.Open(postgres.New(postgres.Config{Conn: db}))

// 3. You have extensive sql.DB middleware/instrumentation
// OpenTelemetry's otelsql, sqlcommenter, etc.
db = otelsql.OpenDB(connString)

// In these cases, you are trading performance for compatibility.
// That is a legitimate trade — just make it knowingly.

Driver portability. If your application genuinely needs to support multiple databases — PostgreSQL in production, SQLite in tests, MySQL for a different tenant — database/sql's common interface is its entire value proposition. pgx native is PostgreSQL-only by design. I emphasize "genuinely" because I have seen many applications claim database portability as a requirement when in practice they have run on PostgreSQL for five years and will continue to do so for five more. Hypothetical portability is not a requirement. It is an aspiration — and a costly one, given the 20–63% throughput you are paying for it.

That said, if your application truly does switch databases — a multi-tenant SaaS where different customers use different databases, a library designed for broad compatibility, an internal tool that runs against multiple data sources — database/sql is the correct choice. Do not contort your architecture to avoid it.

ORM compatibility. GORM, sqlx, ent, and most Go ORMs expect a *sql.DB. You can use pgx as the underlying driver (and you should — it is still faster than lib/pq), but you are operating through database/sql's interface and its constraints. If your ORM is non-negotiable, the 20–33% overhead is the cost of that abstraction. See our ORM vs raw SQL benchmarks for where that overhead actually matters.

sqlx: a middle ground
// sqlx: a middle ground worth mentioning

import "github.com/jmoiron/sqlx"

// sqlx wraps database/sql with convenience methods
// but still operates through the database/sql interface
db := sqlx.MustConnect("pgx", connString)

// Named queries
var users []User
err := db.Select(&users,
    "SELECT id, name, email FROM users WHERE active = $1", true)

// Struct scanning without manual Scan calls
var user User
err = db.Get(&user,
    "SELECT id, name, email FROM users WHERE id = $1", 42)

// sqlx is excellent — genuinely. It reduces boilerplate
// and adds named parameters. But it inherits every
// database/sql limitation: text format, driver.Value boxing,
// no batch queries, no CopyFrom.
//
// If sqlx is your main reason for staying on database/sql,
// consider: pgx v5's CollectRows provides similar ergonomics
// with none of the format overhead.

Instrumentation middleware. The database/sql ecosystem has extensive middleware: OpenTelemetry tracing via otelsql, query logging, connection metrics, sqlcommenter for query attribution. pgx has its own tracer interface (pgx.QueryTracer), and the ecosystem is growing — pgx-otel provides OpenTelemetry integration, for instance — but it is smaller. If your observability stack is deeply invested in otelsql, switching drivers means switching instrumentation, and that is operational work that may exceed the performance benefit.

Team familiarity. I hesitate to list this because it can be used to justify any status quo, but I will be honest: if your team has five years of patterns built around *sql.DB and a new engineer joins every month, the consistency of the existing codebase has real value. A 33% throughput gain matters less than a 33% increase in onboarding confusion if your team is not ready for the change. This is a temporary constraint — it should not be permanent — but it is real.

These are trade-offs, not mistakes. Make them knowingly.

An honest word about what does not change

I have been making the case for pgx native with some vigour, and I believe the case is sound. But a waiter who overstates his case is no waiter at all, so allow me to state plainly what switching interfaces does not fix.

Slow queries remain slow. If your query does a sequential scan across 10 million rows because it lacks an index, the difference between text format and binary format in the driver is noise. The query takes 3 seconds either way. Driver optimization is a multiplier on fast queries, not a remedy for slow ones. Fix your queries first.

Network latency remains network latency. If your database is 15ms away, a 30-microsecond improvement in the driver's parsing speed is 0.2% of total round-trip time. The batch feature helps enormously here — eliminating round trips is the right optimization when network latency dominates — but binary format alone will not noticeably improve a network-bound application.

Connection pool exhaustion is a pool problem, not a driver problem. If your application is running out of connections, switching from sql.DB to pgxpool.Pool gives you better pool management tools, but the root cause is usually too few connections for too many concurrent operations, or long-held transactions that block the pool. Our connection pooling guide addresses this directly.

The overhead is per-row, not per-query. If your application primarily executes single-row lookups — SELECT ... WHERE id = $1 returning one row — the throughput difference is real but modest: roughly 19–25%. The dramatic 63% gains come from queries that return hundreds or thousands of rows. Know your query patterns before estimating your gains.

I offer these caveats not to undermine the recommendation, but to calibrate your expectations. A 33% throughput improvement is substantial and worth pursuing. A 33% throughput improvement that you expected to be 63% feels like a disappointment, and you deserve better than disappointment.

Where the proxy sits in all of this

I should mention something about Gold Lapel's relationship with this particular question, because it is relevant.

Gold Lapel is a PostgreSQL proxy. It sits between your Go application and PostgreSQL, intercepting queries at the wire protocol level. It understands both text and binary format natively — when pgx sends binary-encoded parameters, Gold Lapel decodes them, analyzes the query, applies optimizations, and re-encodes them in binary for PostgreSQL. No format conversion tax. No protocol downgrade.

This means the optimizations Gold Lapel provides — automatic index creation, query rewriting, result caching — work identically whether you use database/sql or pgx native. The 20–63% throughput difference between the two interfaces is real and worth capturing. But a missing index that turns a 2ms query into a 200ms sequential scan dwarfs any driver-level optimization.

Use pgx native to eliminate the driver overhead. Use Gold Lapel to eliminate the query overhead. They are complementary, not competing, optimizations — and I would be a poor waiter indeed if I suggested polishing the silverware while the kitchen was on fire.

The recommendation, stated plainly

If you are writing a PostgreSQL-specific Go application — and if you are reading this article, you almost certainly are — use pgx's native interface. The migration from database/sql is mechanical. The throughput gain is 20–63% depending on your query patterns, with 30–54% being typical for production workloads under concurrent load. You gain access to Batch, CopyFrom, CollectRows, BeginFunc, native PostgreSQL type support, and better prepared statement management. The code is not meaningfully more complex — in many cases, it is simpler.

If you need database portability, ORM compatibility, or existing middleware integration, continue using database/sql with pgx as the driver. You are still using the best Go PostgreSQL driver available. You are simply accessing it through a narrower interface — an interface designed in 2012 for a world where database portability mattered more than database performance. That trade-off may still be correct for your application. Just ensure it is a choice, not an accident.

The performance left on the table is not database/sql's fault, exactly. It was designed as a lowest-common-denominator interface for every SQL database Go might ever talk to. That generality served Go well. But PostgreSQL is not a generic database, pgx is not a generic driver, and if your application is committed to both, the native interface is where the full performance lives.

Now, if you will excuse me, I have some allocations to collect.

Frequently asked questions

Terms referenced in this article

The adjacent question — how pgx's query execution modes interact with connection poolers and prepared statement caching — has its own rather thorough treatment in the pgx QueryExecMode guide. If you have settled on the native interface, the execution mode is the next decision that warrants your attention.