Skip to content

Repository files navigation

pgxporter

CI License

A pgx-native Prometheus exporter library for PostgreSQL 13–18. Intended as an alternative to prometheus-community/postgres_exporter, which is still built on lib/pq — a driver that's been in maintenance mode since 2021.

We leverage pgx/v5, which is low-level, fast, actively maintained, and exposes PostgreSQL-specific features that database/sql doesn't — binary protocol, per-connection type registry, and the BeforeConnect hook that makes first-class cloud IAM auth possible.

This is a library — you compose it into your own main.go rather than running a pre-built binary. See Quickstart below.

Why pgxporter

  • pgx v5 foundation. Binary protocol, prepared-statement cache, persistent pgxpool per database, parallel scrapes via errgroup. postgres_exporter opens a new sql.DB (with MaxOpenConns=1) every scrape and runs collectors serially.
  • Cloud IAM auth, first-class. AWS RDS, GCP CloudSQL, Azure Database — short-lived tokens minted via pgx's BeforeConnect hook. No DSN-rewriting wrappers.
  • Declarative collector extension. Add metrics in YAML without writing Go — the supported replacement for postgres_exporter's deprecated queries.yaml. Custom Go collectors are still available as an escape hatch.
  • Drop-in dashboard compatibility. Opts.MetricPrefix = MetricPrefixPg flips metric names from pg_stat_database_* to pg_database_*, so community postgres_exporter Grafana dashboards work unchanged.
  • PG 13–18 tested. Real-Postgres integration matrix in CI across PG 13.22, 14.19, 15.14, 16.10, 17.6, 18.0.

Feature matrix

Honest comparison against postgres_exporter v0.19.x.

pgxporter postgres_exporter
Driver pgx/v5 (actively maintained) lib/pq (maintenance mode)
Connection pooling pgxpool persistent per DB sql.DB with MaxOpenConns=1, reopened per scrape
Parallel scrapes errgroup fan-out serial
Scrape wall time (HTTP, warm, 1 DB, PG 17.6)¹ 8.7 ms 20.2 ms (2.3× slower)
Series per scrape¹ 2,610 1,967 (−25%)
Scrape context propagation full full
Password auth
Cloud IAM (RDS/CloudSQL/Azure) ✅ via BeforeConnect ❌ DSN-rewriting hack
TLS client certs ✅ via pgx DSN
Cluster pg_stat_ collectors* ✅ (24 total, see below) ✅ (~22)
pg_stat_io (PG 16+)
pg_stat_slru (PG 13+)
pg_stat_progress_* (all 6) partial (vacuum + analyze)
Declarative YAML collectors ExtendFromYAMLFile ❌ (deprecated queries.yaml)
Per-collector enable/disable
Dashboard-compat metric prefix MetricPrefixPg N/A (native)
TLS listener + basic auth via exporter-toolkit via exporter-toolkit
Graceful shutdown Exporter.Shutdown(ctx)
Self-observability metrics ✅ scrape duration / errors / cardinality per-collector partial
PG versions tested 13–18 9.4–18
License Apache-2.0 Apache-2.0
Community Grafana dashboards reuse postgres_exporter's via MetricPrefixPg native
Official Docker image not yet Docker Hub

Where postgres_exporter wins today: PG 9.4–13 long-tail support (we require PG 13+), the mature community dashboard ecosystem, and first-party container images. Coming tickets close the image/dashboards gap.

¹ Head-to-head benchmark against postgres_exporter v0.19.1 — both run as subprocesses against identical fresh PG 17.6 instances with 50 tables × 100 rows seeded, 10-scrape warmup, defaults vs defaults collector sets, client-observed wall time. Full methodology and raw numbers: BENCHMARKS.md.

Requirements

  • Go 1.25+ (pgx 5.9.x requires 1.25)
  • PostgreSQL 13, 14, 15, 16, 17, or 18

Quickstart

package main

import (
	"context"
	"log"
	"net/http"

	"github.com/prometheus/client_golang/prometheus/promhttp"

	"github.com/becomeliminal/pgxporter/exporter"
	"github.com/becomeliminal/pgxporter/exporter/db"
)

func main() {
	ctx := context.Background()

	exp, err := exporter.New(ctx, exporter.Opts{
		DBOpts: []db.Opts{{
			Host:            "localhost",
			Port:            5432,
			User:            "postgres",
			Password:        "postgres",
			Database:        "postgres",
			ApplicationName: "pgxporter",
		}},
	})
	if err != nil {
		log.Fatal(err)
	}
	exp.Register()

	http.Handle("/metrics", promhttp.Handler())
	log.Fatal(http.ListenAndServe(":9187", nil))
}
$ curl -s localhost:9187/metrics | head
# HELP pg_stat_activity_backends Number of backends by state and wait event
# TYPE pg_stat_activity_backends gauge
pg_stat_activity_backends{...}
...

Collectors

All default-on except settings, statements, and subscription. Disable one via exporter.Opts.DisabledCollectors: []string{"locks"}; whitelist an explicit set via Opts.EnabledCollectors.

Name PostgreSQL view Default
activity pg_stat_activity
archiver pg_stat_archiver
bgwriter pg_stat_bgwriter
checkpointer pg_stat_checkpointer (PG 17+)
database pg_stat_database
database_size pg_database_size()
io pg_stat_io (PG 16+)
io_user_indexes pg_statio_user_indexes
io_user_tables pg_statio_user_tables
locks pg_locks (with blocking-chain detection)
progress_analyze pg_stat_progress_analyze
progress_basebackup pg_stat_progress_basebackup
progress_cluster pg_stat_progress_cluster
progress_copy pg_stat_progress_copy
progress_create_index pg_stat_progress_create_index
progress_vacuum pg_stat_progress_vacuum
replication pg_stat_replication (primary)
replication_slots pg_replication_slots
settings pg_settings (numeric-typed subset) ⬜ off
slru pg_stat_slru (PG 13+)
ssl pg_stat_ssl (aggregate)
statements pg_stat_statements ⬜ off
subscription pg_stat_subscription (+ stats PG 15+) ⬜ off
user_indexes pg_stat_user_indexes
user_tables pg_stat_user_tables
wal pg_stat_wal (PG 14+)
wal_receiver pg_stat_wal_receiver (standby)

statements is off by default because pg_stat_statements queries are expensive on busy clusters and the metric cardinality is high. Opt in explicitly.

Configuration

exporter.Opts highlights (see pkg.go.dev for the full reference):

Field Purpose
DBOpts One db.Opts per PostgreSQL server (required, ≥1)
Name Const label database=<Name> on self-metrics when multiple exporters register in one Prometheus instance
CollectionTimeout Per-scrape deadline. 0 → 1 min default; -1 → no timeout
MetricPrefix collectors.MetricPrefixPg for postgres_exporter-compat names
EnabledCollectors Whitelist (empty = use defaults)
DisabledCollectors Subtracts from the resolved set (wins if in both)

db.Opts covers connection basics (Host/Port/User/Password/Database/ApplicationName), statement/lock/transaction timeouts, pgxpool tuning (PoolMaxConns/PoolMaxConnLifetime/etc.), pgx statement-cache mode, and the optional AuthProvider hook. ~20 fields total; see pkg.go.dev/.../exporter/db.

Custom collectors

YAML (the queries.yaml replacement)

- subsystem: custom_rates
  sql: |
    SELECT current_database() AS database, key, count(*) AS c
    FROM business.rate_events
    GROUP BY key
  labels: [key]
  metrics:
    - name: events_total
      help: Events by rate key
      type: counter
      column: c

- subsystem: bloat
  min_pg_version: "14.0"
  sql: SELECT current_database() AS database, schemaname, bytes FROM monitoring.bloat
  labels: [schemaname]
  metrics:
    - name: bytes
      help: Table bloat in bytes
      column: bytes

Load:

if err := exp.ExtendFromYAMLFile("/etc/pgxporter/custom.yaml"); err != nil {
	log.Fatal(err)
}

min_pg_version accepts "14.0", [14, 0], or bare "14". Omit type to emit a gauge. Every SQL must project a database label column so metrics are correctly labelled across multi-DB exporters.

Go interface

For logic that outgrows YAML — custom type conversions, cross-row aggregation, conditional emission — implement the Go interface directly:

type Collector interface {
	Describe(ch chan<- *prometheus.Desc)
	Scrape(ctx context.Context, ch chan<- prometheus.Metric) error
}

Register:

exp := exporter.MustNew(ctx, opts).WithCustomCollectors(myCollector{}, anotherCollector{})

Implementations must thread ctx into every DB call — a pathological query that doesn't honour cancellation can hang the whole scrape cycle.

Cloud IAM auth (RDS / CloudSQL / Azure)

Set db.Opts.AuthProvider and pgx will mint a fresh token for every new pool connection via its BeforeConnect hook. Tokens are rotated implicitly whenever pgxpool opens a new connection, so set PoolMaxConnLifetime shorter than the token's validity window.

AWS RDS example:

import (
	"time"

	"github.com/becomeliminal/pgxporter/exporter/db"
	"github.com/becomeliminal/pgxporter/exporter/db/auth/awsrds"
)

provider, err := awsrds.NewDefault(ctx, "us-east-1")
if err != nil {
	log.Fatal(err)
}

dbOpts := db.Opts{
	Host:                "mydb.cluster-xyz.us-east-1.rds.amazonaws.com",
	Port:                5432,
	User:                "app_iam_user",
	Database:            "appdb",
	ApplicationName:     "pgxporter",
	AuthProvider:        provider,
	PoolMaxConnLifetime: 14 * time.Minute, // RDS IAM tokens are valid 15m; rotate at 14m
}

awsrds.NewDefault chains the standard AWS credential sources (env vars → shared config → IRSA → IMDS). Token minting is pure local SigV4 signing — no network round-trip per connection.

GCP CloudSQL and Azure Database providers follow the same pattern (implement db.AuthProvider):

type AuthProvider interface {
	Password(ctx context.Context, host string, port int, user string) (string, error)
}

postgres_exporter dashboard compatibility

postgres_exporter community Grafana dashboards assume names like pg_database_xact_commit_total. pgxporter defaults to pg_stat_database_xact_commit_total. Flip with Opts.MetricPrefix:

import "github.com/becomeliminal/pgxporter/exporter/collectors"

exp, err := exporter.New(ctx, exporter.Opts{
	MetricPrefix: collectors.MetricPrefixPg,
	DBOpts:       []db.Opts{{...}},
})

Or set it before constructing any exporter (applies library-wide):

collectors.SetMetricPrefix(collectors.MetricPrefixPg)

With this flag the community postgres_exporter dashboard set works unmodified.

For a full switch-over guide — flag mapping, env-var mapping, queries.yamlCollectorSpec, and the recommended parallel-run pattern — see docs/migrating-from-postgres_exporter.md.

Serving metrics over TLS / with basic auth

pgxporter is a Prometheus Collector implementation — it stays framework-agnostic and doesn't bundle an HTTP server, so use any net/http listener you already run. For production setups matching postgres_exporter's ergonomics, pair with prometheus/exporter-toolkit, which provides a --web.config.file flag covering TLS, basic auth, and HTTP/2 out of the box:

import (
	"context"
	"net/http"

	"github.com/prometheus/client_golang/prometheus/promhttp"
	"github.com/prometheus/exporter-toolkit/web"
	webflag "github.com/prometheus/exporter-toolkit/web/kingpinflag"

	"github.com/becomeliminal/pgxporter/exporter"
	"github.com/becomeliminal/pgxporter/exporter/db"
)

func main() {
	webConfig := webflag.AddFlags(kingpin.CommandLine, ":9187")
	kingpin.Parse()

	exp := exporter.MustNew(context.Background(), exporter.Opts{DBOpts: []db.Opts{opts.DB}})
	exp.Register()

	http.Handle("/metrics", promhttp.Handler())
	srv := &http.Server{}
	if err := web.ListenAndServe(srv, webConfig, slog.Default()); err != nil {
		log.Fatal(err)
	}
}

--web.config.file YAML (per exporter-toolkit docs):

tls_server_config:
  cert_file: /etc/pgxporter/tls.crt
  key_file: /etc/pgxporter/tls.key
basic_auth_users:
  prometheus: $2b$12$...  # bcrypt

Graceful shutdown

ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer cancel()

// ... start exporter + HTTP listener ...

<-ctx.Done()

shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()

if err := srv.Shutdown(shutdownCtx); err != nil {
	log.Printf("http shutdown: %v", err)
}
if err := exp.Shutdown(shutdownCtx); err != nil {
	log.Printf("exporter shutdown: %v", err)
}

Exporter.Shutdown takes the scrape mutex so in-flight Collect calls finish before pools close. The per-scrape deadline from Opts.CollectionTimeout bounds the wait.

Example deployments

Single target

import (
	"github.com/becomeliminal/pgxporter/exporter"
	"github.com/becomeliminal/pgxporter/exporter/db"
)

var opts struct {
	DB db.Opts `group:"Postgres"`
}

func main() {
	flags.MustParse(&opts)
	exp := exporter.MustNew(context.Background(), exporter.Opts{DBOpts: []db.Opts{opts.DB}})
	exp.Register()
	// ...
}

Kubernetes:

apiVersion: apps/v1
kind: Deployment
metadata:
  # ...
spec:
  # ...
  template:
    spec:
      containers:
        - name: main
          image: <Go-Binary-Docker-Image>
          args:
            - --application_name=pgxporter
          resources:
            requests:
              memory: 20Mi
              cpu: 5m
            limits:
              memory: 40Mi
              cpu: 50m
          ports:
            - containerPort: 9187
              name: prometheus
          envFrom:
            - secretRef:
                name: your-db-secret

Multi-target

Scrape N databases from one exporter process — the pattern used in production at Liminal across 18 Postgres instances.

import (
	"github.com/becomeliminal/pgxporter/exporter"
	"github.com/becomeliminal/pgxporter/exporter/db"
)

var opts struct {
	FirstDB  db.Opts `namespace:"first" env-namespace:"FIRST"`
	SecondDB db.Opts `namespace:"second" env-namespace:"SECOND"`
}

func main() {
	flags.MustParse(&opts)
	exp := exporter.MustNew(context.Background(), exporter.Opts{
		DBOpts: []db.Opts{opts.FirstDB, opts.SecondDB},
	})
	exp.Register()
	// ...
}

Kubernetes:

apiVersion: apps/v1
kind: Deployment
metadata:
  # ...
spec:
  # ...
  template:
    spec:
      containers:
        - name: main
          image: <Go-Binary-Docker-Image>
          args:
            - --first.application_name=pgxporter
            - --second.application_name=pgxporter
          ports:
            - containerPort: 9187
              name: prometheus
          envFrom:
            - secretRef:
                name: your-first-db-secret
              prefix: FIRST_
            - secretRef:
                name: your-second-db-secret
              prefix: SECOND_

Exporter self-metrics

Emitted at the default pg_stat prefix (flip with Opts.MetricPrefix):

  • pg_stat_up (gauge) — 1 if the last scrape succeeded, 0 on error.
  • pg_stat_exporter_scrapes_total (counter) — cumulative scrape count.
  • pg_stat_scrape_duration_seconds{collector} (histogram) — per-collector wall time.
  • pg_stat_scrape_errors_total{collector} (counter) — per-collector error count. Pre-initialised to 0 for every resolved collector so rate() returns a clean zero on healthy instances.
  • pg_stat_metric_cardinality{collector} (gauge) — metric count emitted by a collector on its last scrape. Early-warning signal for cardinality explosions.

All self-metrics carry a database=<Opts.Name> const label when Opts.Name is set.

License

Apache-2.0 — see LICENSE and NOTICE. Relicensed from AGPL-3.0 in April 2026 to align with the Prometheus exporter ecosystem (postgres_exporter, client_golang, exporter-toolkit are all Apache-2.0) and unblock corporate adoption blocked by AGPL §13 network-distribution obligations.

Dashboards

See dashboards/ for pgxporter-health.json (exporter self-metrics) plus the compatibility guide for reusing community postgres_exporter dashboards via MetricPrefixPg.

Benchmarks

See BENCHMARKS.md for head-to-head scrape-duration numbers.

Changelog

See CHANGELOG.md.

Contributing

See CONTRIBUTING.md.

About

Prometheus exporter for PostgreSQL server-side metrics.

Topics

Resources

Contributing

Stars

4 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages