PostgreSQL Plugins¶
When to read this: the pipeline needs PostgreSQL extraction, table writes, schema adaptation, SQL-native DLQ/replay, HA read routing, or Kafka-to-Postgres runtime helpers.
The PostgreSQL family in agora-etl-plugins 0.4.x is a production-ready
flagship backend. It includes checkpoint-aware SQL sources, pooled table sinks,
SQL/COPY/COPY + MERGE write modes, schema-aware table adaptation,
PostgreSQL-backed DLQ, observability reports, replica staleness controls, and
Kafka-to-Postgres runtime helpers.
Install¶
pip install "agora-etl-plugins[postgres]"
The extra installs psycopg[binary] and psycopg_pool.
Public surface¶
| Component | Kind | Use it for |
|---|---|---|
PostgresSource |
Source | Streaming query results with optional checkpoint-aware resume. |
PostgresSink |
Sink | Writing rows with SQL, COPY, or staged COPY + MERGE. |
PostgresSchemaAdapter |
Sink wrapper | Auto-create and additive auto-alter from runtime SchemaMiddleware metadata. |
PostgresDLQSink |
DLQ sink | Persisting dead-letter records to a table. |
PostgresDLQSource |
DLQ source | Reading bounded replay windows from a DLQ table. |
PostgresConfig, PostgresPluginConfig |
Config | Env/model-backed connection, TLS, auth, pool, and statement settings. |
PostgresConnectionConfig, PostgresTLSConfig, PostgresAuthConfig |
Config | Explicit connection wiring for source/sink/DLQ. |
PostgresWriteSafetyPolicy |
Sink safety | Strict or align-to-target write behavior. |
PostgresSinkWriteError, PostgresPoisonRecordInfo |
Error model | Classifying schema drift, constraint violations, type mismatch, and unknown failures. |
PostgresPrometheusExporter |
Observability | Prometheus rendering for source/sink/DLQ metrics. |
KafkaPostgresRuntime and builders |
Runtime helper | Kafka-to-Postgres wedge runtime, poison DLQ config, metrics, and acceptance reports. |
Entry-points installed by the package:
| Group | Key | Target |
|---|---|---|
agora.sources |
postgres |
PostgresSource |
agora.sources |
postgres_dlq_source |
PostgresDLQSource |
agora.sinks |
postgres |
PostgresSink |
agora.sinks |
postgres_schema_adapter |
PostgresSchemaAdapter |
agora.sinks |
postgres_dlq |
PostgresDLQSink |
PostgresSource¶
PostgresSource streams rows from a SQL query and maps each row into a
pipeline record.
Important constructor options:
| Option | Meaning |
|---|---|
dsn or connection |
Connection source. connection accepts PostgresConnectionConfig. |
query, params |
SQL text and base parameters. |
row_mapper |
Sync/async callable. May accept context when its signature supports it. |
batch_size |
fetchmany() size. |
checkpoint_field + checkpoint_param |
Single-cursor resume. |
checkpoint_fields + checkpoint_params |
Composite-cursor resume. |
on_record_error |
Fail closed or log/drop/continue row mapping failures. |
statement_timeout_ms |
Applies a PostgreSQL statement timeout for reads. |
transaction_read_only, transaction_isolation_level |
Read transaction controls. |
read_routing |
dsn, primary, standby, prefer_standby, or any. |
max_replica_replay_lag_s, on_replica_stale |
Standby staleness guard and fallback behavior. |
fetch_strategy |
client or server_side. |
server_side_cursor_name, server_side_cursor_withhold |
Server-side cursor controls. |
Checkpointing is enabled only when checkpoint field/parameter mapping is provided. Without it, the source reports full-rerun recovery semantics.
PostgresSink¶
PostgresSink buffers mapped rows and flushes to PostgreSQL.
Important constructor options:
| Option | Meaning |
|---|---|
table |
Target table; schema-qualified names are supported. |
row_mapper |
Converts pipeline records to row dictionaries. |
conflict_key |
One key or a list of keys used for upsert identity. |
batch_size |
Buffer size before flush. |
upsert=True |
Upsert by conflict_key. |
insert_mode |
sql, copy, or copy_merge. |
pool_size |
Sink-owned write connection pool size. |
max_rows_per_statement |
Optional row chunk limit. |
max_parameters_per_statement=32000 |
Parameter safety limit for SQL mode. |
write_safety_policy |
Strict row shape or align rows to target table columns. |
poison_record_sink |
Optional DLQ sink for failed flush buffers. |
pool_acquire_timeout_s, pool_health_check, pool_max_lifetime_s, pool_max_idle_s |
Pool controls. |
allow_quoted_identifiers |
Opt-in for quoted identifier support. |
Write modes:
| Mode | Best for | Constraints |
|---|---|---|
sql |
General insert/upsert | Honors parameter limits and chunking. |
copy |
Append-only bulk loads | Requires upsert=False. |
copy_merge |
Large upsert-heavy loads | Stages with COPY, then merges into target table. |
When upsert=True, open() validates that target table constraints exactly
match conflict_key. If PostgresSchemaAdapter wraps the sink, that preflight
is deferred until after schema DDL runs.
Quickstart¶
from agora import DeliveryConfig, Pipeline
from agora_plugins.postgres import PostgresSink, PostgresSource
source = PostgresSource(
dsn="postgresql://app:secret@localhost:5432/app",
query="""
SELECT id, email, updated_at
FROM customers
WHERE (
updated_at > %(cursor)s
OR (updated_at = %(cursor)s AND id > %(last_id)s)
)
ORDER BY updated_at, id
""",
params={"cursor": "2026-01-01T00:00:00+00:00", "last_id": 0},
checkpoint_fields=["updated_at", "id"],
checkpoint_params={"updated_at": "cursor", "id": "last_id"},
row_mapper=lambda row: {
"customer_id": row["id"],
"email": row["email"].lower(),
"synced_at": row["updated_at"],
},
)
sink = PostgresSink(
dsn="postgresql://app:secret@localhost:5432/app",
table="customer_projection",
row_mapper=lambda record: record,
conflict_key="customer_id",
insert_mode="copy_merge",
batch_size=500,
pool_size=4,
)
summary = await (
Pipeline(source)
.build(sink, config=DeliveryConfig(batch_size=100))
.run(max_records=10_000)
)
Incremental extract pattern¶
Use checkpoint_field/checkpoint_param for simple cursor queries:
from agora import DeliveryConfig, MapMiddleware, Pipeline
from agora.sinks.io.stdout import StdoutSink
from agora_plugins.postgres import PostgresSource
def normalise(record: dict) -> dict:
return {
key: (value.isoformat() if hasattr(value, "isoformat") else value)
for key, value in record.items()
}
source = PostgresSource(
dsn="postgresql://app:secret@localhost:5432/app",
query="""
SELECT *
FROM events
WHERE updated_at > %(cursor)s
ORDER BY updated_at
""",
params={"cursor": "2026-01-01T00:00:00+00:00"},
checkpoint_field="updated_at",
checkpoint_param="cursor",
row_mapper=lambda row: row,
fetch_strategy="server_side",
statement_timeout_ms=30_000,
)
summary = await (
Pipeline(source, id="postgres_incremental")
.pipe(MapMiddleware(normalise, name="normalise"))
.build(
StdoutSink(),
config=DeliveryConfig(batch_size=1_000, checkpoint_every=10),
)
.run()
)
Read routing and replica staleness¶
PostgresSource can ask PostgreSQL/libpq for read routing through
target_session_attrs-style behavior:
source = PostgresSource(
dsn="postgresql://app:secret@postgres-ha/app",
query="SELECT id, updated_at FROM events WHERE updated_at > %(cursor)s",
params={"cursor": "2026-01-01T00:00:00+00:00"},
row_mapper=lambda row: row,
checkpoint_field="updated_at",
checkpoint_param="cursor",
read_routing="prefer_standby",
max_replica_replay_lag_s=2.0,
on_replica_stale="route_primary",
)
Use route_primary only when primary fallback is acceptable for the workload.
Use fail_closed when stale standby reads are safer than surprising primary
traffic.
Schema adapter¶
PostgresSchemaAdapter wraps a sink and applies runtime schema metadata from
SchemaMiddleware.
from agora import DeliveryConfig, Pipeline
from agora.schema import SchemaMiddleware
from agora_plugins.postgres import PostgresSchemaAdapter, PostgresSink
from agora_plugins.redis import RedisStreamSource
sink = PostgresSchemaAdapter(
PostgresSink(
dsn="postgresql://app:secret@localhost:5432/app",
table="public.customer_projection",
row_mapper=lambda record: record,
conflict_key="customer_id",
),
auto_create=True,
auto_alter=True,
schema_lock_timeout_ms=5_000,
schema_advisory_lock=True,
)
summary = await (
Pipeline(
RedisStreamSource(
url="redis://localhost:6379",
stream="customers:raw",
group="customer-sync",
consumer="worker-1",
)
)
.pipe(SchemaMiddleware(table="public.customer_projection"))
.build(sink, config=DeliveryConfig(batch_size=100))
.run(max_records=5_000)
)
The adapter can create tables and add missing columns. For existing tables with rows, new non-null schema columns are added nullable because no default/backfill value exists. Use migration tooling instead when schema changes require review, backfill, or destructive changes.
DLQ and poison writes¶
Use PostgresDLQSink when failed records should be inspectable through SQL and
participate in database backup, retention, and access controls.
from agora import DeliveryConfig, Pipeline
from agora_plugins.postgres import PostgresDLQSink, PostgresSink, PostgresSource
dlq = PostgresDLQSink(
dsn="postgresql://app:secret@localhost:5432/app",
table="ops.agora_dlq",
)
sink = PostgresSink(
dsn="postgresql://app:secret@localhost:5432/app",
table="processed_events",
row_mapper=lambda record: record,
conflict_key="id",
poison_record_sink=dlq,
poison_record_pipeline_id="orders-postgres",
)
summary = await (
Pipeline(
PostgresSource(
dsn="postgresql://app:secret@localhost:5432/app",
query="SELECT id, payload FROM inbox ORDER BY id",
row_mapper=lambda row: row,
)
)
.build(sink, config=DeliveryConfig(batch_size=100))
.run()
)
Sink write failures are classified as schema drift, constraint violation, type mismatch, or unknown. When a poison DLQ sink is configured, failed buffered rows can be routed with that classification metadata.
Observability¶
PostgreSQL components expose:
- source health snapshots and recovery-contract snapshots
- source/sink/DLQ metrics snapshots
- sink latency histograms for connect, pool acquire, and flush
- Prometheus text rendering through
PostgresPrometheusExporter - acceptance reports with threshold objects
Production checklist¶
- Back every
conflict_keywith a real unique constraint or primary key. - Use
copy_mergefor large upsert-heavy loads andcopyonly for append-only loads withupsert=False. - Keep source queries ordered by checkpoint fields.
- Use
server_sidefetch for large reads that should not materialize the whole result client-side. - Prefer
sslmode="verify-full"unless deployment policy owns the override. - Use
read_routingandmax_replica_replay_lag_sdeliberately in HA setups. - Use
PostgresSchemaAdapteronly when automatic additive schema changes are acceptable. - Use PostgreSQL DLQ when operators need SQL filtering, retention, backup, and audit over failed records.
Boundaries¶
PostgreSQL is the right fit when records are naturally relational, operators debug through SQL, or replay state belongs in a database table.
It is the wrong fit when the workload is primarily event transport, when table shape changes faster than migrations can govern it, or when the pipeline needs a broker rather than an operational store.