Skip to content

Transforms

A transform changes a record between the runtime decoding it and the plugin receiving it. Transforms are part of the runtime, not the plugin, and the same eight are available to every connector.

sinkfetch + decodetransformsconsumesourcepoll + decodetransformsroute + producefields added here can be routed on

Transforms are declared under [transforms] in a connector definition, one table per type, each with an enabled flag that defaults to on. A definition without a [transforms] table passes records through untouched.

toml
[transforms.unwrap_envelope]
field = "payload"

[transforms.add_fields]
fields = [
  { key = "ingested_at", value = { computed = "date_time" } },
  { key = "source", value = { static = "orders-cdc" } },
]

[transforms.delete_fields]
fields = ["password", "ssn"]

Ordering

Each transform type appears at most once, and the order in which different types are applied is not defined. Combine transforms whose results do not depend on order, or chain two connectors when they do.

The eight

TransformDoesActs on
add_fieldsInserts fields that are not already presentJSON object
update_fieldsSets fields, with a conditionJSON object
delete_fieldsRemoves named fieldsJSON object
filter_fieldsKeeps or drops fields by key and value patternsJSON object
unwrap_envelopeReplaces the payload with one of its fieldsJSON object
proto_convertProtobuf to JSON or backPayload encoding
flatbuffer_convertFlatBuffers to JSON or backPayload encoding
avro_convertAvro to JSON or backPayload encoding

The five field transforms operate on the top level of a JSON object. A raw or text schema, or a JSON payload that is an array or scalar, passes through them unchanged.

Field values

add_fields and update_fields take the same value forms.

FormExampleProduces
static{ static = "orders" }Any JSON literal: string, number, boolean, object, array
computed = "date_time"RFC 3339 timestamp
computed = "timestamp_seconds"Current time as an integer
computed = "timestamp_millis"Same, milliseconds
computed = "timestamp_micros"Same, microseconds
computed = "timestamp_nanos"Same, nanoseconds
computed = "uuid_v4"A fresh identifier per record

update_fields adds a condition per field.

conditionEffect
alwaysOverwrite. The default
key_existsOnly change fields that are present
key_not_existsOnly add fields that are absent. Same as add_fields
toml
[transforms.update_fields]
fields = [
  { key = "status", value = { static = "processed" }, condition = "key_exists" },
  { key = "version", value = { static = 2 }, condition = "always" },
]

Filtering fields

filter_fields is the general form. keep_fields are always retained. Every other field is tested against patterns.

  • Matched and include_matching = true: kept.
  • Matched and include_matching = false: dropped.
  • Unmatched: the opposite in each case.

A pattern has an optional key_pattern and an optional value_pattern. Both must match when both are given.

key_patternMatches when the key
exactequals the string
starts_with, ends_with, containshas the string in that position
regexmatches the expression
value_patternMatches when the value
equalsis that JSON value
containsis a string containing the substring
regexis a string matching the expression
greater_than, less_than, betweenis a number in that range
is_null, is_not_nullis or is not null
is_string, is_number, is_boolean, is_object, is_arrayhas that type
toml
[transforms.filter_fields]
keep_fields = ["id"]
include_matching = false
patterns = [
  { key_pattern = { starts_with = "_" } },
  { key_pattern = { regex = "^tmp_" }, value_pattern = { is_null = true } },
]

This keeps id, drops every field starting with _, and drops any tmp_* field whose value is null.

Unwrapping envelopes

unwrap_envelope replaces the payload with the value of one field. The usual case is a CDC or webhook envelope whose interesting content is under payload or after.

{"op": "u", "ts": 1767225600,"after": { "id": 7 }}unwrap_envelopefield = "after"{ "id": 7 }

The field's value becomes the whole record, whatever its type. A record without the field passes through unchanged.

Format conversions

proto_convert, flatbuffer_convert and avro_convert change the encoding of the payload rather than its content. Each has a source_format and a target_format, one of which is json and the other the binary format, plus a way to find the schema.

TransformSchema from
proto_convertschema_path to a .proto with include_paths for imports and message_type for the root, or descriptor_set bytes, or schema_registry_url
flatbuffer_convertschema_path to an .fbs, include_paths, root_table_name
avro_convertschema_path, or inline schema_json

All three share the following.

OptionEffect
field_mappingsA table renaming fields on the way through
conversion_options.strict_modeFail on fields the schema does not know rather than dropping them
conversion_options.pretty_jsonReadable output when converting to JSON
conversion_options.include_metadataAdd the record's topic and offset to the result
toml
[transforms.proto_convert]
source_format = "proto"
target_format = "json"
schema_path = "/etc/picomq-connectors/schemas/user.proto"
message_type = "example.User"

[transforms.proto_convert.field_mappings]
userId = "user_id"

A conversion changes the schema of what the plugin sees. A sink declared with schema = "proto" and a proto_convert to JSON hands its plugin JSON records, so the plugin configuration should agree, payload_format = "json" for Postgres for instance.

Where transforms run

  • Inside the runtime, on the batch, between decode and the plugin.
  • None of the shipped transforms drops a record or splits one into several. They filter fields, not messages. Record-level filtering belongs in the source query or the sink plugin.
  • They hold no state and call out to nothing, which keeps them cheap enough to run on every record.

The transform configuration a connector is running with is visible at GET /sinks/{key}/transforms and GET /sources/{key}/transforms on the runtime's HTTP API.