Skip to content

Postgres source

Reads rows or changes from PostgreSQL and produces them as JSON records. Two modes are available. Polling selects new rows from tables by a monotonic column. CDC reads the write-ahead log through a logical replication slot and emits every insert, update and delete.

Both modes follow the stage-and-apply pattern, so a cursor only moves, and rows are only marked or deleted, after the broker has acknowledged the batch.

TypeSource
Librarylibpicomq_connector_postgres_source
Ships inThe pico-connectors image
Modespolling, cdc
Output schemajson, or raw / text when extracting a single column
StatePer-table tracking offsets (polling). Replication slot position (CDC)
On replayRows are re-read and re-produced. Deduplicate at the sink
pollingSELECTid > last, LIMIT nbatchcandidate: max idproduceruntimeackmark rowscdcpeek slotno consumebatchcandidate: lsnproduceruntimeackadvance slot

Quick start

CDC on one table, routed into a topic per tenant.

toml
type = "source"
key = "orders_cdc"
enabled = true
version = 0
name = "Orders CDC"
path = "libpicomq_connector_postgres_source"

[[topics]]
topic = { strategy = "field", path = "data.tenant", template = "orders.{value}" }
schema = "json"
batch_length = 1000
linger_time = "5ms"
create_topics = true

[plugin_config]
connection_string = "postgres://user:pass@db:5432/app"
mode = "cdc"
tables = ["public.orders"]
replication_slot = "picomq_orders"

Polling on two tables, one topic.

toml
[[topics]]
topic = "events"
schema = "json"

[plugin_config]
connection_string = "postgres://user:pass@db:5432/app"
mode = "polling"
tables = ["public.events", "public.audit"]
tracking_column = "id"
poll_interval = "5s"
batch_size = 500

Polling mode

Each poll() sleeps poll_interval, then for each table in tables runs the following query.

sql
SELECT * FROM "public"."events"
WHERE "id" > <last offset for this table>
ORDER BY "id" ASC
LIMIT <batch_size>
BehaviourDetail
CursorThe highest tracking_column value seen, kept per table in the state store
First runNo WHERE clause, so the whole table from the beginning. initial_offset sets a starting point instead
Column typeAny type that sorts and compares with >. Integers and timestamps are typical. Strings work but sort lexically
processed_columnAdds AND "<column>" = FALSE to the query, and on ack sets the column to TRUE for the rows read
delete_after_readOn ack, deletes the rows read by primary_key_column
custom_queryReplaces the generated query. Placeholders $table, $offset, $limit, $now, $now_unix are substituted

Polling sees inserts and, if tracking_column is an updated-at timestamp, updates. It does not see deletes.

Stage and apply in polling

The per-table cursor and any pending UPDATE or DELETE are staged when the batch is returned and applied only on ack. A crash between produce and ack means the rows are read again on restart, which is the at-least-once promise.

delete_after_read and processed_column need a primary_key_column if the primary key is not tracking_column. The cleanup statement is WHERE <pk> IN (...) over every row in the batch.

CDC mode

CDC uses the test_decoding output plugin that ships with PostgreSQL, through a logical replication slot. Each poll() sleeps poll_interval and then peeks up to batch_size changes with pg_logical_slot_peek_changes. Peeking leaves the slot where it is. The slot is advanced to the last LSN in the batch only on ack.

BehaviourDetail
Slotreplication_slot, created on open() if it does not exist. Default picomq_slot
Existing slotReused if its plugin is test_decoding. Rejected with an error otherwise
FilteringOnly tables in tables and operations in capture_operations are emitted. Everything else in the WAL is peeked and skipped
TransactionsBEGIN and COMMIT markers are dropped. Changes inside are emitted in commit order
Empty pollNothing is staged and the slot is not touched

Slot retention

A replication slot holds WAL until it is advanced. A stopped or broken source keeps its slot, and the primary keeps every WAL segment since, until disk fills or max_slot_wal_keep_size cuts it off. Drop the slot when decommissioning a source: SELECT pg_drop_replication_slot('picomq_slot').

Each source needs its own slot. Two sources sharing a slot would each advance it past changes the other had not seen.

Configuration

All keys go under [plugin_config].

Common

KeyTypeDefaultMeaning
connection_stringstringrequiredA libpq URL. Redacted in the API
modestringrequiredpolling or cdc
tableslistrequiredTables to read, schema-qualified where needed. In CDC, an empty list captures every table
poll_intervalduration10sSleep between polls
batch_sizeint1000Rows per table per poll, or changes per poll
max_connectionsint10Pool size
include_metadatabooltruePolling only. false nests the row one level deeper, at data.data, with the envelope otherwise unchanged. Leave it on
snake_case_columnsboolfalseConvert camelCase column names to snake_case in the output
max_retriesint3Attempts for each query
retry_delayduration1sDelay between attempts
verbose_loggingboolfalseLog every poll at info

Polling only

KeyTypeDefaultMeaning
tracking_columnstringidColumn the cursor follows
initial_offsetstringnoneStarting cursor for tables with no saved state
primary_key_columnstringtracking_columnColumn used in cleanup statements
processed_columnstringnoneBoolean column to filter on and set TRUE after ack
delete_after_readboolfalseDelete rows after ack
custom_querystringnoneFull replacement query with placeholders
payload_columnstringnoneEmit one column as the whole payload instead of the row
payload_formatstringjsonEncoding of payload_column. json, json_direct, bytea, text

CDC only

KeyTypeDefaultMeaning
replication_slotstringpicomq_slotSlot name, one per source
capture_operationslistallSubset of INSERT, UPDATE, DELETE
cdc_backendstringbuiltinOnly builtin is available in the shipped build

Output

Every record is a JSON object with the same envelope in both modes.

json
{
  "table_name": "orders",
  "operation_type": "UPDATE",
  "timestamp": "2026-09-03T21:15:04.118Z",
  "data": { "id": 7, "tenant": "acme", "total": 42.5 },
  "old_data": { "id": 7 }
}
FieldPollingCDC
table_nameThe table, as configuredThe table, unqualified
operation_typeAlways SELECTINSERT, UPDATE or DELETE
timestampTime the row was readTime the change was read, not the commit time
dataThe rowNew tuple for insert and update, old tuple for delete
old_dataAbsentOld key for updates with a changed key, absent otherwise

Routing rules address fields inside the envelope, so path = "data.tenant" rather than path = "tenant". A sink that wants the bare row can apply unwrap_envelope with field = "data".

Records carry no key and no headers. Add a key route or a transform if a sink needs them.

Single-column payloads

In polling mode, payload_column emits one column's value as the entire record and drops the envelope. This is for tables that already hold a serialized message, an outbox table for instance.

payload_formatColumn typeTopic schema
jsontext or jsonb holding JSONjson
json_directjsonb, passed through without re-encodingjson
textany text typetext
byteabytearaw

State

ModeStored in the runtime's state storeStored in Postgres
PollingPer-table cursor, rows processed, last poll timeprocessed_column flags or deleted rows
CDCNothing that matters for resumptionThe slot position

Losing the state file in polling mode restarts every table from initial_offset or the beginning. Losing it in CDC mode changes nothing, since the slot is the cursor. Dropping the slot loses every change since it was last advanced.

Requirements

Both modes

  • Network access from the runtime to the database.
  • SELECT on the tables.

Polling

  • UPDATE when processed_column is set, DELETE when delete_after_read is on.
  • An index on tracking_column, or every poll is a sequential scan.

CDC

  • wal_level = logical in postgresql.conf. The source checks this on open() and refuses to start otherwise.
  • A role with REPLICATION, or the rds_replication role on RDS and Aurora.
  • max_replication_slots high enough for one slot per source.
  • The test_decoding plugin, which is part of every standard PostgreSQL distribution.

Troubleshooting

SymptomCause
WAL level must be 'logical' for CDCSet wal_level = logical and restart PostgreSQL
Replication slot ... already exists with plugin ...The slot was created by something else. Use a different replication_slot or drop it
CDC produces nothing though the table changesThe table is not in tables, or the operation is not in capture_operations. Check with SELECT * FROM pg_logical_slot_peek_changes('picomq_slot', NULL, 10)
Polling re-reads the same rows every polltracking_column is not monotonic, or the state file is not persisted between restarts
Polling misses updatesPolling by an insert id only sees new rows. Track an updated_at column or use CDC
Disk growing on the primaryA slot is held by a stopped source. Drop it or restart the source
Rows arrive twice after a restartExpected after a crash between produce and ack. See Delivery guarantees