Summary
For a table column whose type is a custom Postgres composite type (e.g.
CREATE TYPE money_with_currency AS
(currency_code varchar, amount numeric)
),
TableReader's backfill path always emits null for that column, while the same column streamed via a live insert/update/delete correctly emits Postgres's text representation of the value (e.g. "(EUR,1234.56)"). This means every row loaded via backfill silently loses data in that column — deterministically, on every backfill (initial, retried, or manually re-triggered), not just intermittently.
Environment
- Self-hosted Sequin, v0.14.6
- RabbitMQ sink, default (non-transform) message envelope
- Postgres source, logical replication
Reproduction
CREATE TYPE public.money_with_currency AS (currency_code varchar, amount numeric);
CREATE TABLE public.payments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
amount public.money_with_currency NOT NULL
);
INSERT INTO public.payments (amount) VALUES (ROW('EUR', 42.00)::public.money_with_currency);
- Create a sink with initial_backfill: true (or manually trigger a backfill) against a table containing a
money_with_currency column with existing data.
- Inspect the resulting action: "read" message for that row.
- Separately, UPDATE the same row (or insert a new one) so it's captured by the live replication stream instead, and inspect that message.
Expected
Both messages represent amount the same way — ideally the same text form the live path already produces
("(EUR,42.00)"), or some structured equivalent, but consistently.
Actual
- Backfill (action: "read"): "amount": null
- Live (action: "insert"/"update"): "amount": "(EUR,42.00)"
Root cause
Sequin.Postgres.load_rows/2
(https://github.com/sequinstream/sequin/blob/v0.14.6/lib/sequin/postgres/postgres.ex#L890-L936), which every
backfilled row passes through in TableReader.fetch_batch/6, special-cases a fixed list of Postgrex value types
(Pgvector, bytea/bit/varbit, uuid, ranges, intervals) and falls through to this catch-all for everything else:
This is the catch-all when encode is not implemented
Jason.Encoder.impl_for(value) == Jason.Encoder.Any ->
Logger.debug("[Postgres] No Jason.Encoder for #{inspect(value)}", column: col.name, table: table.name)
nil
Postgrex decodes a custom composite type into a struct with no registered Jason.Encoder, so it silently becomes nil here — logged only at debug, easy to miss in production. This code path is specific to the backfill's typed SELECT (Postgres.result_to_maps → Postgres.load_rows); the live replication path takes Postgres's own WAL text representation directly and never goes through Postgrex's typed decoding or this function at all, which is why it doesn't hit the same gap.
Impact
Any table backfilled (initial load, a table added to include_tables after already having data, or a manually
re-triggered catch-up backfill) loses every column of a custom composite type, silently, for every row the
backfill touches. This isn't a transient failure — it will reproduce identically on every future backfill of the
same table until a decoder is registered for that type. We found it affects a double-digit percentage of rows
across multiple tables in production before noticing, since nothing in the pipeline (or Sequin itself) surfaces
it above debug log level.
Suggested fix direction
- At minimum, log this at warning (or expose it via Sequin's health/metrics) rather than debug, so it's not silently discoverable only by downstream data audits.
- Ideally, fall back to Postgres's own text representation of the value (the same thing the WAL/live path already produces) for any type without a registered Jason.Encoder, instead of nil — consistent behavior between backfill and streaming, and no data loss for types Sequin doesn't have first-class support for.
Summary
For a table column whose type is a custom Postgres composite type (e.g.
),
TableReader's backfill path always emits null for that column, while the same column streamed via a live insert/update/delete correctly emits Postgres's text representation of the value (e.g. "(EUR,1234.56)"). This means every row loaded via backfill silently loses data in that column — deterministically, on every backfill (initial, retried, or manually re-triggered), not just intermittently.
Environment
Reproduction
money_with_currencycolumn with existing data.Expected
Both messages represent amount the same way — ideally the same text form the live path already produces
("(EUR,42.00)"), or some structured equivalent, but consistently.
Actual
Root cause
Sequin.Postgres.load_rows/2
(https://github.com/sequinstream/sequin/blob/v0.14.6/lib/sequin/postgres/postgres.ex#L890-L936), which every
backfilled row passes through in TableReader.fetch_batch/6, special-cases a fixed list of Postgrex value types
(Pgvector, bytea/bit/varbit, uuid, ranges, intervals) and falls through to this catch-all for everything else:
This is the catch-all when encode is not implemented
Postgrex decodes a custom composite type into a struct with no registered Jason.Encoder, so it silently becomes nil here — logged only at debug, easy to miss in production. This code path is specific to the backfill's typed SELECT (Postgres.result_to_maps → Postgres.load_rows); the live replication path takes Postgres's own WAL text representation directly and never goes through Postgrex's typed decoding or this function at all, which is why it doesn't hit the same gap.
Impact
Any table backfilled (initial load, a table added to include_tables after already having data, or a manually
re-triggered catch-up backfill) loses every column of a custom composite type, silently, for every row the
backfill touches. This isn't a transient failure — it will reproduce identically on every future backfill of the
same table until a decoder is registered for that type. We found it affects a double-digit percentage of rows
across multiple tables in production before noticing, since nothing in the pipeline (or Sequin itself) surfaces
it above debug log level.
Suggested fix direction