# Events

> Understand the events Supabase ETL delivers during ongoing replication.

- Canonical HTML: https://supabase.github.io/etl/explanation/events/
- Agent-readable Markdown: https://supabase.github.io/etl/explanation/events.md
- Source: https://github.com/supabase/etl/blob/main/site/content/docs/explanation/events.mdx

During ongoing replication, Supabase ETL delivers events decoded from Postgres
logical replication via `write_events()`. This page documents their Rust shapes
and PostgreSQL semantics.

## Event Overview [#event-overview]

| Event         | Description       | Has Table ID |
| ------------- | ----------------- | ------------ |
| `Begin`       | Transaction start | No           |
| `Commit`      | Transaction end   | No           |
| `Insert`      | New row added     | Yes          |
| `Update`      | Row modified      | Yes          |
| `Delete`      | Row removed       | Yes          |
| `Truncate`    | Table cleared     | Yes          |
| `Relation`    | Table schema      | Yes          |
| `Unsupported` | Unknown event     | No           |

## Data Modification Events [#data-modification-events]

These events carry **row data** and are associated with specific tables.

### Row Images [#row-images]

Data modification events use **row-image helper types**:

```rust
pub enum UpdatedTableRow {
Full(TableRow),
Partial(PartialTableRow),
}

pub enum OldTableRow {
Full(TableRow),
Key(TableRow),
}
```

`TableRow` is a **complete dense row**. Its values are ordered to match the
replicated table-column order.

`PartialTableRow` is used when an update row is **not complete**. It exposes:

* `total_columns()`: the number of replicated columns in the table schema;
* `values()`: present values in replicated table-column order, excluding missing columns;
* `missing_column_indexes()`: zero-based replicated-column indexes for values ETL could not reconstruct.

`OldTableRow::Full(row)` contains a **complete old row**. `OldTableRow::Key(row)`
contains only replica-identity columns, densely packed in replicated
table-column order.

### Insert [#insert]

A new row was added to a table.

```rust
pub struct InsertEvent {
pub commit_lsn: PgLsn,
pub tx_ordinal: u64,
pub replicated_table_schema: ReplicatedTableSchema,
pub table_row: TableRow,
}
```

### Update [#update]

An existing row was modified.

```rust
pub struct UpdateEvent {
pub commit_lsn: PgLsn,
pub tx_ordinal: u64,
pub replicated_table_schema: ReplicatedTableSchema,
pub updated_table_row: UpdatedTableRow,
pub old_table_row: Option<OldTableRow>,
}
```

`updated_table_row` is the **authoritative post-update payload**:

* `UpdatedTableRow::Full` when ETL knows every replicated column value after decoding the update.
* `UpdatedTableRow::Partial` when PostgreSQL emitted `UnchangedToast` fields that ETL could not reconstruct safely.

`old_table_row` provides optional context for row matching, key changes, and
reconstruction.

#### Unchanged Toast [#unchanged-toast]

PostgreSQL may encode an unchanged toasted value as `UnchangedToast` in the
update's new tuple instead of resending the full column value. ETL can turn the
update into `UpdatedTableRow::Full` only when that value can be recovered from
the old-side row image:

* a `FULL` old row can recover unchanged toasted values for any replicated column;
* a key-only old row can recover unchanged toasted values only for
  replica-identity columns included in that key image;
* no old row means unchanged toasted values cannot be recovered from the event.

When any `UnchangedToast` field cannot be recovered, ETL emits
`UpdatedTableRow::Partial` with known values and missing column indexes instead
of pretending the unknown value is `NULL` or a replacement value.

Destinations that need complete replacement rows, full before/after comparison,
or complete audit records should require `REPLICA IDENTITY FULL` for tables with
toasted columns, or keep enough prior state to fill missing values themselves.

#### Old Row Mapping [#old-row-mapping]

ETL maps PostgreSQL pgoutput update tuple markers directly:

| pgoutput marker         | ETL field                      |
| ----------------------- | ------------------------------ |
| `O` old tuple           | `Some(OldTableRow::Full(row))` |
| `K` old key             | `Some(OldTableRow::Key(row))`  |
| no old tuple/key marker | `None`                         |
| `N` new tuple           | `updated_table_row`            |

PostgreSQL chooses which old-side marker to emit from the table's replica
identity:

| REPLICA IDENTITY                | `old_table_row` contains for published updates                                                        |
| ------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `FULL`                          | `Some(OldTableRow::Full(row))`                                                                        |
| `DEFAULT` with a primary key    | `Some(OldTableRow::Key(row))` when PostgreSQL determines the old key must be logged, otherwise `None` |
| `DEFAULT` without a primary key | Source `UPDATE` is rejected when the table publishes updates                                          |
| `USING INDEX`                   | `Some(OldTableRow::Key(row))` when PostgreSQL determines the old key must be logged, otherwise `None` |
| `NOTHING`                       | Source `UPDATE` is rejected when the table publishes updates                                          |

For `UPDATE`, PostgreSQL only sends an old key image under `DEFAULT` or
`USING INDEX` when it determines the old key must be logged. In practice, that
happens when:

* any replica-identity column changed, or
* any replica-identity column contains external data, such as a toasted value
  that must be available from the old tuple.

That means non-identity updates under `DEFAULT` or `USING INDEX` often arrive with `old_table_row = None`.
Under `FULL`, PostgreSQL sends a full old row for every published update.
A `FULL` update with `old_table_row = None` is not a pgoutput shape.

When handling updates:

1. Treat `updated_table_row` as the post-update payload and handle
   `UpdatedTableRow::Partial` explicitly.
2. Treat `OldTableRow::Key` as replica-identity values, not necessarily the
   primary key. Use `OldTableRow::Full` for complete before-images.
3. If `old_table_row` is `None`, match or upsert from the new row according to
   the destination's keying model.

### Delete [#delete]

A row was removed from a table.

```rust
pub struct DeleteEvent {
pub commit_lsn: PgLsn,
pub tx_ordinal: u64,
pub replicated_table_schema: ReplicatedTableSchema,
pub old_table_row: Option<OldTableRow>,
}
```

For `DELETE`, valid PostgreSQL publications send an old-side image:

| REPLICA IDENTITY                | `old_table_row` contains for published deletes               |
| ------------------------------- | ------------------------------------------------------------ |
| `FULL`                          | `Some(OldTableRow::Full(row))`                               |
| `DEFAULT` with a primary key    | `Some(OldTableRow::Key(row))`                                |
| `DEFAULT` without a primary key | Source `DELETE` is rejected when the table publishes deletes |
| `USING INDEX`                   | `Some(OldTableRow::Key(row))`                                |
| `NOTHING`                       | Source `DELETE` is rejected when the table publishes deletes |

**Important implications:**

* Deletes do not carry a new row image. `old_table_row` is the delete payload.
* `OldTableRow::Key(row)` again means replica-identity columns only, not necessarily the table's primary key.
* Consumers that require full old rows for deletes need `REPLICA IDENTITY FULL`.
* The Rust field is optional at the event API boundary, but PostgreSQL pgoutput
  populates it for every published delete. Tables without usable replica
  identity fail at the source when they publish deletes.
* Destination implementations should decide up front whether key-only deletes
  are enough. If they are not, require `REPLICA IDENTITY FULL` for those tables.

### Truncate [#truncate]

One or more tables were truncated (all rows deleted).

```rust
pub struct TruncateEvent {
pub commit_lsn: PgLsn,
pub tx_ordinal: u64,
pub options: i8,
pub truncated_tables: Vec<ReplicatedTableSchema>,
}
```

> **Note**
>
> A single `Truncate` event can affect multiple tables when using
> `TRUNCATE ... CASCADE`.

## Transaction Events [#transaction-events]

These events mark transaction boundaries.

### Begin [#begin]

Marks the start of a transaction.

```rust
pub struct BeginEvent {
pub commit_lsn: PgLsn,
pub tx_ordinal: u64,
pub timestamp: i64,
pub xid: u32,
}
```

### Commit [#commit]

Marks successful transaction completion.

```rust
pub struct CommitEvent {
pub commit_lsn: PgLsn,
pub tx_ordinal: u64,
pub flags: i8,
pub end_lsn: PgLsn,
pub timestamp: i64,
}
```

## Schema Events [#schema-events]

### Relation [#relation]

Provides **table schema information**. Sent before data events for a table and
again after supported schema changes.

```rust
pub struct RelationEvent {
pub replicated_table_schema: ReplicatedTableSchema,
}
```

Relation messages are connection-local protocol metadata. PostgreSQL can emit
the same relation again after a reconnect even when the source schema did not
change. A `RelationEvent` therefore has no durable event sequence key and does
not consume a transaction ordinal.

PostgreSQL pgoutput builds relation messages by walking the table descriptor in
`pg_attribute.attnum` order and skipping columns that are not published. It
sends tuple data in the same order as the relation message.

ETL builds replication and identity masks from relation-message column names, so
the order is not needed to decide which columns are included. That name-based
matching is sound because PostgreSQL live column names are unique within a table
schema version. The order matters only after the masks are applied: stored table
schemas are also ordered by `attnum`, so
`ReplicatedTableSchema::column_schemas()` becomes a positional view that matches
the tuple payloads exactly, even when a publication filters columns.

For DDL behavior, including add/drop/rename semantics, default and nullability
handling, and current limitations, see
[Schema Changes](https://supabase.github.io/etl/explanation/schema-changes.md).

## Begin/Commit Behavior [#begincommit-behavior]

During initial sync, parallel Table Sync Workers may deliver `Begin` and
`Commit` markers more than once. Ignore them if you do not need transaction
markers; otherwise deduplicate them by `commit_lsn` plus `tx_ordinal`. Row
writes must still be idempotent because ETL provides at-least-once delivery.

## Understanding Event Sequence Keys [#understanding-event-sequence-keys]

Transaction and data events include a commit &#x2A;*LSN (Log Sequence Number)** plus
a transaction-local ordinal. Together, these fields provide stable ordering for
idempotent destination writes. Relation events are not sequenced because they
are connection-local protocol metadata.

### `commit_lsn` and `tx_ordinal` [#commit_lsn-and-tx_ordinal]

| Field        | Meaning                                              | Use Case                                      |
| ------------ | ---------------------------------------------------- | --------------------------------------------- |
| `commit_lsn` | Position where the transaction will commit           | Transaction grouping, recovery checkpoints    |
| `tx_ordinal` | Zero-based order of the event within its transaction | Ordering and idempotency within a transaction |

Events in one transaction share a `commit_lsn`; `tx_ordinal` distinguishes
their order. Use the pair as a sequence key for ordered writes and replay
detection. See [Logical Replication](https://supabase.github.io/etl/explanation/concepts.md#lsn-log-sequence-number)
for LSN background.

## Event Batching [#event-batching]

ETL batches events before calling `write_events()`. A batch may contain events from **multiple tables, multiple transactions, and mixed event types**.
Schema changes do not end a batch: because batching is driven by size and time,
one batch may contain multiple `Relation` events and multiple schema changes.

**Ordering requirement:** Process events for the same destination row in order.
Events for independent rows may be processed concurrently.