# Data Contracts for Database Events

> What a consumer may rely on in a Debezium change event, which ALTER TABLE changes break it, registry compatibility modes, and a DDL gate you can run.

Canonical page: <https://sandgraal.github.io/letstalkcdc/cdc-data-contracts/>

When the producer is a database, the schema is a side effect of table DDL. Learn what a consumer may rely on in a change event, which ALTER TABLE statements break it, and how to catch the break before it ships.

Which DDL breaks what A DDL gate you can run

## A schema nobody designed

When a service publishes an event through an API, someone decides which fields exist and what they mean. In change data capture the producer is a database table. The fields of an event are the table's columns at the moment of the change, so every `ALTER TABLE` is also an edit to a published schema, made by whoever ran the migration, often without a consumer in mind. The schema is a side effect of DDL, not a deliberate interface.

Confluent's data-contracts documentation defines a data contract as a formal agreement between an upstream and a downstream component on the structure and semantics of data in motion, and says a schema is only one element of it: structure, integrity constraints, metadata, rules, and a way to change over time. This page applies that idea where the upstream component is a database you may not control, and stays on the parts that are specific to CDC: what the envelope promises, what each kind of DDL does to it, how to enforce it, and who owns it. It builds on [Schema Evolution](https://sandgraal.github.io/letstalkcdc/schema-evolution/) (registry modes, subject naming, rollout) and [Event Envelope](https://sandgraal.github.io/letstalkcdc/event-envelope/) (the fields one by one) and does not repeat them.

|  | An API or service that publishes events | A table captured by CDC |
| --- | --- | --- |
| Who decides the fields | The team that owns the event. | Whoever owns the table's DDL, which may be an application framework's migration tool. |
| What a change looks like | A new schema version, reviewed as an API change. | A migration that was reviewed as a database change. |
| A rename | A deliberate version, often with both names for a while. | One field gone and another present. The Debezium pages describe no rename marker in the event. |
| What you can set at the source | Anything the publisher builds. | Which columns are captured, how types are mapped, and transformations on the way out (below). |

**What a contract does not do.** It describes shape and meaning. It does not change delivery. Debezium documents at-least-once delivery, so a compatible schema can still be delivered twice, late, or replayed. The sink still upserts on the primary key and applies a change only if its source log position is newer. Nothing on this page makes a hop exactly-once; see [Is CDC exactly-once, per hop](https://sandgraal.github.io/letstalkcdc/is-cdc-exactly-once/).

**As of:** 2026-10-09, against the Debezium 3.7 documentation (the “stable” pages), the Confluent Platform Schema Registry documentation, and the Avro 1.12.0 specification. Setting names and defaults move between releases; where a setting below matters to you, check your version's page.

## The contract surface: what the envelope promises

A Debezium change event has a key and a value. The value carries `before`, `after`, `source`, `op` and timestamps. Your consumers depend on some of that by design and on the rest by accident. Write down which is which. The table uses the PostgreSQL connector page; other connectors differ in the position fields, so read yours.

| Element | May rely on (documented) | Do not rely on |
| --- | --- | --- |
| Message key | The primary key columns, or the unique key if there is no primary key. They stay in the key even when `column.include.list` or `column.exclude.list` filters the value. `message.key.columns` can override the key. | A key on every record. With no primary or unique key the key is null, and a truncate event has a null key. |
| A primary-key change | A delete event and a tombstone for the old key, then an event with the new key. | That the row keeps one identity. To a keyed sink a key change is a delete plus an insert. |
| `op` | `c`, `u`, `d`, `r` (snapshot reads only), and on PostgreSQL `t` (truncate) and `m` (logical decoding message). | Three values only. A sink that treats everything that is not `d` as an upsert will upsert a `t`. |
| `before` and `after` | `after` holds the new row for create, update and read. `before` exists as the PostgreSQL `REPLICA IDENTITY` setting allows. | A full `before` image. With the default replica identity, the PostgreSQL page's update example carries only the key columns whose values changed in `before`, so this part of the contract is a database setting. |
| Source position | The position fields your connector documents (`lsn` and `txId` on PostgreSQL). This is what a sink orders by, with one exception: in Debezium's PostgreSQL source (`PostgresSourceInfoStructMaker` on the main branch, read 2026-10-09; check your version) `lsn` and `txId` are left out when `source.snapshot` is `incremental`. Those reads (`op` `r`) carry no position, so the sink needs an explicit rule for them; see [Testing a CDC pipeline](https://sandgraal.github.io/letstalkcdc/test-your-pipeline/#contract). | The top-level `ts_ms`. It is the connector's processing time from the Connect task's JVM clock; `source.ts_ms` is the time of the change in the database. Neither is a unique position. |
| Tombstone | With `tombstones.on.delete=true` (the default) a delete event is followed by a record with the same key and a null value. | It still being there: an SMT can drop it, and Kafka compaction removes it after `delete.retention.ms`. See [Deletes that stay deleted](https://sandgraal.github.io/letstalkcdc/deletes-stay-deleted/). |
| Field order | Avro readers match record fields by name, and the specification says the order may differ between writer and reader. | Column order in the value. Nothing in the Debezium pages promises one, and a column added in the middle of a table is a DDL change, not a promise. |
| Optional flags, types and defaults | What the connector documents for your version and settings. | Staying the same. DDL changes them, and so do connector settings (below). |

### Two ways the contract changes without a migration

- **Connector settings.** The PostgreSQL page maps temporal types through `time.precision.mode` and decimals through `decimal.handling.mode`, so changing either changes the field type consumers see. `field.name.adjustment.mode` and `schema.name.adjustment.mode` (both `none` by default) can replace characters that Avro names do not allow, so a column called `first-name` may reach consumers under a different field name depending on them.
- **Connector upgrades.** Debezium's page warns that with `include.unknown.datatypes=true` the binary representation of an unsupported type may change between releases, and that once a type is supported it is sent as a logical type, which “would require adjustments by consumers”. Treat the connector version as part of the contract, and run the contract test (below) before an upgrade reaches production.

## Which DDL breaks which consumer

The registry cannot see your DDL; it sees the schema the converter derives from it. Debezium's nullable columns become optional Connect fields, and Confluent's Avro converter writes an optional field as a union with `null` and a default of `null` ([AvroData.java](https://github.com/confluentinc/schema-registry/blob/master/avro-data/src/main/java/io/confluent/connect/avro/AvroData.java), read on 2026-10-09; check the converter version you run). That makes “nullable column” mean “optional field” to the registry, and the rest follows from Confluent's compatibility table. The table below uses Avro. Protobuf and JSON Schema follow different rules (Confluent's table shows widening, for one, accepted differently in Protobuf), so check your format.

| DDL | What consumers see | BACKWARD | FORWARD | FULL |
| --- | --- | --- | --- | --- |
| `ADD COLUMN` nullable | A new optional field. Rows that do not change produce no event, so it fills in as rows are updated or re-snapshotted. | accepted | accepted | accepted |
| `ADD COLUMN ... NOT NULL DEFAULT x` | A field with a default, if the connector propagates it. The PostgreSQL page says it tries to, and that the default in the Kafka schema can appear late or early. | accepted | accepted | accepted |
| `DROP COLUMN` that was nullable | The field disappears. Anything that read it gets nothing. | accepted | accepted | accepted |
| `DROP COLUMN` that was `NOT NULL` with no default | The same, but old readers have no default to fall back on. | accepted | rejected | rejected |
| `RENAME COLUMN`, nullable | One optional field gone, another optional field present. Nothing links them. | accepted | accepted | accepted |
| `RENAME COLUMN`, `NOT NULL` with no default | A required field gone and a required field added. | rejected | rejected | rejected |
| Widen a type (`int` to `bigint`) | The Connect type goes from int32 to int64. | accepted | rejected | rejected |
| Narrow a type | The reverse. | rejected | accepted | rejected |
| Change to an unrelated type (`int` to `text`) | No Avro promotion between them. | rejected | rejected | rejected |
| `SET NOT NULL` | The optional flag goes away: the union loses its `null` branch. | rejected | accepted | rejected |
| `DROP NOT NULL` | The field may now be null. | accepted | rejected | rejected |
| Change or drop a default | The default in the schema changes (PostgreSQL with `pgoutput` refreshes on a default change; other plug-ins may not notice until a later refresh). | Not a row in Confluent's table. By the Avro rules a default is used only when the writer lacks the field, so this alone is accepted; it can still hurt later (see transitive modes). |  |  |

Sources and what was checked. Rows 1, 3, 4, 7 and 8 are rows of Confluent's Avro table (“add optional field”, “remove optional field”, “remove required field”, “widen a scalar type”, “narrow a scalar type”). The others are derived: rows 2, 5 and 6 by combining rows, the type-change row from the Avro schema-resolution rules (numeric promotion goes `int` to `long` to `float` to `double`, and `string` and `bytes` promote to each other, so a PostgreSQL `text` to `bytea` change passes every mode; there are no other promotions, so `int` to `text` is rejected), and the nullability rows from the table's union-variant rows, because Confluent says Avro expresses nullability as a union with `null`. This repository's tests check every cell above against a small model of the Avro resolution rules, and the model reproduces all 24 cells of Confluent's Avro table. That is a check of the page against the documentation, not a run of a real Schema Registry. The registry's own check is the authority for your subject.

### The rename is the dangerous one

A nullable column renamed is accepted in every mode, because to the registry it is a removed optional field and an added optional field, both of which are always allowed. The connector keeps running, the registry stays green, and every consumer that reads `email` now reads nothing while `email_address` fills with data nobody asked for. A sink that stores what it reads keeps its old column, which stops changing, and ignores the new one. No exception is raised. Only a contract test on the consumer's side (below) or a reconciliation check on the data notices.

Avro has aliases for exactly this, but the specification describes them as a reader-side mapping: a reader's schema names the old name as an alias. The Debezium pages I read do not describe the connector emitting aliases. Treat a rename as a drop plus an add and migrate with the sequence in the versioning section.

Inference, not a measurement: that a rename reaches consumers this way follows from the event being built from the table's current columns and from the absence of a rename field in the envelope on the connector pages. It was not run against a live database for this page. SQL Server is different, below.

## Registry modes, and which one fits a CDC topic

[Schema Evolution](https://sandgraal.github.io/letstalkcdc/schema-evolution/#compatibility) explains the modes. Here is the part that matters for a database producer, from Confluent's documentation.

| Type | New schema is checked against | Upgrade order, per Confluent |
| --- | --- | --- |
| `BACKWARD` (the default) | The latest version only. | Consumers first, then producers. |
| `BACKWARD_TRANSITIVE` | Every earlier version. | Consumers first. |
| `FORWARD`, `FORWARD_TRANSITIVE` | The latest version, or every earlier version. | Producers first, after making sure data written with older schemas is not available to consumers. |
| `FULL`, `FULL_TRANSITIVE` | The latest version, or every earlier version, in both directions. | Either order. |
| `NONE` | Nothing. | Upgrade producers and consumers together, or use a new topic. |

- **Why BACKWARD is the default.** Confluent says it is so you can rewind consumers to the beginning of the topic: a reader on the new schema can read old data. CDC topics get replayed (a new sink, a re-snapshot, an offset reset), so this fits. Confluent also notes that Protobuf's best practice is `BACKWARD_TRANSITIVE`.
- **Non-transitive lets drift through.** Suppose a table gets `ADD COLUMN tier text NOT NULL DEFAULT 'standard'` and later `ALTER COLUMN tier DROP DEFAULT`. Version 2 can be read by a version 3 reader, because version 2 data contains `tier`, so `BACKWARD` accepts version 3. But version 1 data has no `tier` and version 3 has no default to supply, so a replay from the start of the topic fails on the oldest records. `BACKWARD_TRANSITIVE` rejects version 3 at registration. This is the model's result for the schemas as described; it assumes the connector propagates the dropped default, which the PostgreSQL page says it tries to do.
- **BACKWARD puts consumers first.** That order is easy to say and hard to get when the DDL is run by a team that does not know your consumers. It is the reason for the process section below. `FULL` removes the ordering constraint at the price of rejecting more changes (see the table above).
- **Per table.** With the default subject naming each table's topic has its own subject, so compatibility is enforced and set per table. Set the mode on purpose per subject (`PUT /config/<subject>`), not by inheriting the global one.
- **Where it bites.** With `auto.register.schemas` at its default of `true`, the serializer tries to register a schema it has not seen as it serializes a record that uses it. The API reference lists `409 Conflict` (“Incompatible schema”) for a register call, so an incompatible schema is refused, the converter throws, and the connector task fails (next sections).

Confluent's data-contracts feature goes further: tags, ownership metadata, domain rules and migration rules that transform data between versions. The documentation says rules need Confluent Platform Enterprise (7.4 or later) or Confluent Cloud with the Advanced Stream Governance package, and that Kafka Connect on Confluent Cloud does not run rules. Nothing on this page depends on it.

## How Debezium learns about DDL, by engine

How a connector finds out that a table changed differs by database, and so does what breaks.

- **MySQL: the schema history topic.** The MySQL page says the binlog contains DDL, the connector parses it, and it records every DDL statement with its binlog position in a separate schema history topic (`schema.history.internal.kafka.topic`, `schema.history.internal.kafka.bootstrap.servers`) so it can rebuild each table's structure at the point where it restarts. The page says that topic is for the connector's internal use only, that it must have a single partition, and that you should not partition it. If it is lost or corrupted, `snapshot.mode=recovery` rebuilds it from the source tables, but the page warns: “Do not use this mode to perform a snapshot if schema changes were committed to the database after the last connector shutdown.” If DDL ran while the connector was down, the recovered history would describe the new tables for older binlog events; the page does not give an alternative in that warning, so check your version's page for the supported route before you rely on recovery after DDL. Consumers that want to be told about DDL should read the separate schema change topic, controlled by `include.schema.changes` (default `true`). The page also says the format of that topic is “in an incubating state and is subject to change without notice”, so it is a signal, not a contract to parse.
- **PostgreSQL: no DDL in the stream.** The page says logical decoding does not support DDL changes, so the connector cannot report them to consumers. It refreshes its schema from the database when it detects a change during streaming, and says `pgoutput` is more eager about it than the other plug-ins (a default change triggers a refresh with `pgoutput` but not with others). There is no schema history topic to read or to lose, and also no DDL event to react to: the first sign of a change is a different shape of event.
- **SQL Server: capture instances.** The SQL Server page says that, because of how SQL Server CDC works, a Debezium user cannot update the capture tables and has to coordinate with a database operator, who creates a new capture instance after the schema change (offline or online). It also says SQL Server does not allow some schema changes on a table with CDC enabled, naming a column rename and a column type change, and that after a change between `NULL` and `NOT NULL` the emitted records do not show the change until a new capture instance exists. For that engine the DDL runbook is part of the contract.

### Shape the contract at the source: column lists

`column.include.list` captures only the columns you name, and `column.exclude.list` captures every column except the ones you name; set one, not both. Key columns are always included in the key. An include list turns the contract into an allow-list: a column added to the table later does not appear in events until you add it to the list, which is a useful property for a column that might hold personal data (see [Security & PII](https://sandgraal.github.io/letstalkcdc/security/)). An exclude list does the opposite, and a new column flows to consumers on its own. On PostgreSQL, `skip.messages.without.change` only applies when the table's replica identity is `FULL`.

## Shaping the contract on the way out: SMTs and the outbox

A [single message transform](https://sandgraal.github.io/letstalkcdc/glossary/#smt) sits between the database's shape and the consumer's. Two are worth knowing, and they pull in opposite directions.

### Flattening with `ExtractNewRecordState`

The event flattening SMT replaces the envelope with the `after` row. That is a simpler contract and a smaller one: `op`, `before` and `source` are gone unless you ask for them. Debezium's page shows `add.fields=op,table,lsn,source.ts_ms` producing `__op`, `__table`, `__lsn` and `__source_ts_ms`. If your sink orders by source position, put the position into the flattened record on purpose. On PostgreSQL the position is absent for incremental-snapshot reads (see the source position row above), so `__lsn` is absent or null for those and the sink needs a rule for them:

```
transforms=unwrap
transforms.unwrap.type=io.debezium.transforms.ExtractNewRecordState
transforms.unwrap.add.fields=op,lsn
transforms.unwrap.delete.tombstone.handling.mode=rewrite
```

With `rewrite` the SMT keeps the delete as a record with `__deleted: true` (and removes the tombstone), which keeps a delete marker a position-guarded sink can use. The option's default is a version trap: in the 3.7 page the options table gives `delete.tombstone.handling.mode` a default of `tombstone`, while the “Behavior” paragraph of the same page still says the SMT drops delete records by default. Do not rely on a default; set it, and check your version. `drop` removes both the delete event and the tombstone, and a target that never receives a delete never removes the row. Metadata for a delete is added only with `rewrite`. The Debezium page recommends applying the SMT selectively with a predicate, because heartbeat, schema-change and transaction messages have a different structure.

### The outbox event router

The outbox pattern turns the problem round: the contract is a table you designed for the purpose, not your domain tables. Debezium's router assumes an outbox table with `id`, `aggregatetype`, `aggregateid`, `type` and `payload` columns (the page lists options for taking them from other columns). The router puts `aggregateid` in the message key, the `id` in a header (the page suggests using it to remove duplicates), and routes by `aggregatetype`. Its documentation expects outbox rows to be inserts only: an update is handled according to `table.op.invalid.behavior` (`warn` by default; the other settings are `error` and `fatal`), and deletes are filtered out. Your domain tables can then change without the event changing, and the payload and `type` become the versioned contract. Two caveats from the page: the routed message in its example carries a key, an id header and the payload, not the `source` block, and `table.fields.additional.placement` adds columns of the outbox table, not the log position. So a sink that must order by log position has no position to read unless your application writes a per-aggregate sequence into the outbox row. The delivery side of the outbox is the subject of [Exactly-Once Semantics](https://sandgraal.github.io/letstalkcdc/exactly-once/#outbox).

## Evolve the topic or version it

Evolve in place when the change is compatible with every consumer you are willing to break, which for a long-retention topic means the transitive modes. For a change the registry rejects, Confluent says to upgrade all producers and consumers at once or, more likely, to create a new topic and move applications to it, rather than hold two incompatible versions in one topic. For a database producer the practical version of that is expand and contract:

1. **Expand.** Add the new column as nullable (accepted in every mode). Consumers ignore it.
2. **Backfill.** Populate it from the old column. Every updated row is a change event, so a big backfill is a burst of traffic on the topic; do it in batches.
3. **Move consumers** to read the new field, one at a time, each checking its own contract test.
4. **Wait out the replay window.** Old events stay in the topic for the retention period. A consumer that rewinds will read records with the old shape.
5. **Contract.** Drop the old column only when nothing reads it, including any consumer that could replay history.

A rename is steps 1 to 5 with a copy in the middle. If the change cannot be expanded (a type change to an unrelated type), the new topic is the honest answer, and a re-snapshot on the new topic is part of the cost.

## A DDL gate you can run

[Testing a CDC pipeline](https://sandgraal.github.io/letstalkcdc/test-your-pipeline/#contract) shows a contract test that checks each recorded event against the envelope. The gate below is the other half: it runs when someone proposes a DDL change, before it ships. It compares the field list of the `after` struct before and after the change (the shape of `schema.fields` in a Debezium JSON-with-schema record) with the columns *one consumer* reads and assumes. It is a consumer-driven check: it does not ask whether the schema is valid in general, it asks whether this consumer still works. Everything in it is deliberately small.

**ddl-gate.mjs (Node 20+, no dependencies)**

```js
// ddl-gate.mjs  (Node 20+, no dependencies)  run: node ddl-gate.mjs
// Compares the "after" struct of a Debezium JSON-with-schema record before and
// after a proposed DDL change with the columns one consumer actually reads.
// A field is { field, type, optional, default? }, as in the Connect schema.
const before = [
  { field: "id", type: "int32", optional: false },
  { field: "email", type: "string", optional: false },
  { field: "name", type: "string", optional: true },
  { field: "tier", type: "string", optional: false, default: "standard" },
];

// What this consumer reads, and what it assumes about each column.
const reads = {
  id: { type: "int32", nullable: false },
  email: { type: "string", nullable: false },
  name: { type: "string", nullable: true },
  tier: { type: "string", nullable: false },
};

function gate(beforeFields, afterFields, consumerReads) {
  const byName = (fields) => new Map(fields.map((f) => [f.field, f]));
  const was = byName(beforeFields);
  const now = byName(afterFields);
  const added = [...now.values()].filter((f) => !was.has(f.field));
  const findings = [];
  for (const [column, want] of Object.entries(consumerReads)) {
    const f = now.get(column);
    if (!f) {
      // A rename reaches the consumer as one field gone and another present.
      const twin = added.find(
        (a) => a.type === was.get(column).type && !(a.field in consumerReads),
      );
      const hint = twin ? `; "${twin.field}" is new with the same type (rename?)` : "";
      findings.push(["BREAK", column, `removed${hint}`]);
      continue;
    }
    if (f.type !== want.type) {
      findings.push(["BREAK", column, `type ${want.type} -> ${f.type}`]);
    } else if (f.optional && !want.nullable) {
      findings.push(["BREAK", column, "can now be null; the consumer assumes it cannot"]);
    }
    const old = was.get(column);
    if (old.default !== f.default) {
      findings.push(["WARN", column, `default ${old.default} -> ${f.default}`]);
    }
  }
  for (const f of added) {
    if (!(f.field in consumerReads)) findings.push(["OK", f.field, "added, not read"]);
  }
  return findings;
}

const rewrite = (fields, change) => change(structuredClone(fields));
const scenarios = {
  "add nullable phone": (f) => [...f, { field: "phone", type: "string", optional: true }],
  "drop name": (f) => f.filter((c) => c.field !== "name"),
  "rename email": (f) => f.map((c) => (c.field === "email" ? { ...c, field: "email_address" } : c)),
  "widen id to bigint": (f) => f.map((c) => (c.field === "id" ? { ...c, type: "int64" } : c)),
  "email becomes nullable": (f) => f.map((c) => (c.field === "email" ? { ...c, optional: true } : c)),
  "change tier default": (f) => f.map((c) => (c.field === "tier" ? { ...c, default: "basic" } : c)),
};

let broken = 0;
for (const [name, change] of Object.entries(scenarios)) {
  const findings = gate(before, rewrite(before, change), reads);
  broken += findings.filter(([level]) => level === "BREAK").length;
  console.log(`${name}:`);
  for (const [level, column, message] of findings) console.log(`  ${level.padEnd(5)} ${column}: ${message}`);
}
process.exitCode = broken > 0 ? 1 : 0;
```

Run it with `node ddl-gate.mjs`. The six scenarios are the script's own, a sample of the rows in the DDL table above. Expected output, and an exit code of 1 because at least one scenario breaks:

```
add nullable phone:
  OK    phone: added, not read
drop name:
  BREAK name: removed
rename email:
  BREAK email: removed; "email_address" is new with the same type (rename?)
  OK    email_address: added, not read
widen id to bigint:
  BREAK id: type int32 -> int64
email becomes nullable:
  BREAK email: can now be null; the consumer assumes it cannot
change tier default:
  WARN  tier: default standard -> basic
```

This repository's test runs the script exactly as printed and compares the output. The rename hint is a heuristic (a removed field and an added field of the same type), and it is a hint. The script never talks to a database or a registry.

### Getting the “after DDL” schema

The hard part is the second field list. Two honest options. Exact: apply the migration to a scratch database with a connector attached, record the first event, and take the schema from it. Cheap: derive it from the migration by hand, as the script's scenarios do, and accept that the connector's type mapping could differ from your reading. A registry check complements either:

```
# Written from the Schema Registry API reference; not run against a registry for this page.
# candidate.json is {"schema": "<the proposed Avro schema, as an escaped string>"}
curl -s -X POST \
  -H "Content-Type: application/vnd.schemaregistry.v1+json" \
  --data @candidate.json \
  "http://localhost:8081/compatibility/subjects/dbserver1.public.customers-value/versions?verbose=true"
# {"is_compatible": false, "messages": [...]}   (messages only with verbose=true)
```

The command is written from the API reference and was not run against a registry. The reference describes this endpoint as performing the same check as a register call for that subject, using the subject's configured level (or the global level if the subject has none): against the latest version for the non-transitive modes, and against all earlier versions for the transitive ones. To make the registry the gate rather than the converter, the Confluent converter documentation describes `auto.register.schemas=false` with `use.latest.version=true`: the serializer then uses the latest registered version instead of registering one, and while `latest.compatibility.strict` stays at its default (`true`) it raises an error if that version is not backward compatible with the schema of the object being serialized. That was not tried with a Debezium connector here; check how your converter version behaves before you depend on it.

## Who owns it: the process around DDL

Confluent's page says the upstream component enforces the contract and the downstream component can assume the data conforms. For a database producer, upstream is the team that runs the migrations. The points below are practice, not a documented rule, and are where a contract usually breaks.

| Question | A workable answer |
| --- | --- |
| Who owns the table's events? | One named team per captured table, written next to the connector's `table.include.list`, not “the platform team”. |
| Who approves DDL on it? | The owner, plus one person from each consuming team listed for that topic. Additive changes need a notice; anything the table above rejects needs sign-off. |
| What runs on the pull request? | The gate above for each consumer's declared reads, and the registry compatibility check for the subject. |
| What mode is each subject in, and why? | Recorded in the repository. Transitive where consumers can replay from the start. With `BACKWARD`, consumers upgrade before the DDL lands, so the DDL date is a consumer deadline. |
| What happens after it ships? | Watch connector state and lag for a fixed period. A failed task is the first sign of a rejected schema. |

## What goes wrong, and what catches it

| Failure | What happens | What catches it |
| --- | --- | --- |
| A column rename that the registry accepts | Consumers read nothing from the old field; nothing errors. | A consumer-side contract test on the fixture or the gate; a reconciliation check on counts of non-null values. The registry cannot. |
| The registry rejects the schema | The register call returns `409`; the converter throws; by default the Connect task fails, because Connect's documentation says an error in conversion fails the connector unless you tolerate errors. The Confluent error-handling article says there is no dead letter queue for source connectors. | A task state alert. While the task is down it reads nothing: on PostgreSQL the slot stops advancing and WAL is kept for it, as [Replication slots and WAL growth](https://sandgraal.github.io/letstalkcdc/postgres-replication-slots/) explains. Roll back the DDL or move to a new topic. |
| A consumer cannot decode a record | For a sink connector, `errors.tolerance=all` with `errors.deadletterqueue.topic.name` sends it to a DLQ and carries on. The record is out of position order from then on. | A DLQ depth alert, and [DLQ triage](https://sandgraal.github.io/letstalkcdc/dlq-triage/). Re-driven records arrive late, which only a sink that applies a change when its position is newer absorbs. |
| A type widened for new consumers only | Accepted under `BACKWARD`; a consumer still on the old type cannot read new records. | Upgrade order (consumers first), or `FULL` modes. The gate flags the type change. |
| A default that arrives late or early | The PostgreSQL page says the Kafka schema's default can lag the database's, or lead it if a refresh happens while records are queued. | Do not make a consumer depend on a default being current; test both shapes. |
| Nullability not reflected (SQL Server) | After `NULL` to `NOT NULL` or back, records keep the old optional flag until a new capture instance exists. | Make the capture-instance step part of the DDL runbook. |
| A lost or corrupt schema history (MySQL) | The connector cannot rebuild table structures at its restart position. | Retain and monitor the topic. `snapshot.mode=recovery` rebuilds it from the source tables, but not if DDL was committed after the last shutdown (the MySQL page's warning, above); then check your version's page for the supported route. |

Every row above is about shape. Duplicates and late records are a separate failure, covered by the sink: the same change delivered twice, or an older change arriving after a newer one, is harmless to a sink that upserts on the primary key and applies a change only if its source log position is newer. A schema contract does not replace that, and a stricter registry mode does not remove it.

## See a schema change

The [Change Feed Playground](https://sandgraal.github.io/letstalkcdc/playground/) has a Schema Evolution scenario with a schema walkthrough that adds or drops a column while events stream. From its source, it has add and drop buttons only: no rename and no registry. It shows that a column appears or disappears in the events; it does not show a compatibility check or a failed connector, and it was not run for this page. The [Schema Evolution](https://sandgraal.github.io/letstalkcdc/schema-evolution/) page links the same scenario.

### Data Contracts: Knowledge Check

Test whether you can say what a consumer may rely on and what a DDL change does to it.

Q1

#### A migration runs ALTER TABLE customers RENAME COLUMN email TO email_address. What does a consumer of the change events see?

A A rename event that names the old and the new column

B Nothing, until the connector is restarted

C Events without email and with email_address; nothing says they are the same column

D The registry rejects every rename, so nothing reaches the topic

Q2

#### With Confluent Schema Registry at its default compatibility setting, what is a new schema version checked against?

A Every earlier version of the subject

B Only the latest registered version, because the default BACKWARD is not transitive

C Nothing; the default is NONE

D Only the first version, which acts as the baseline

Q3

#### Which column change does Confluent's Avro compatibility table accept under BACKWARD but reject under FORWARD?

A Adding a column with a default

B Dropping a nullable column

C Adding a NOT NULL column with no default

D Widening a column from int to bigint

Q4

#### The registry rejects a schema the Debezium connector tries to register, and the connector's converter throws. What happens by default?

A The task fails; Kafka Connect has no dead letter queue for source connectors

B The event is skipped and the connector carries on

C The event goes to a dead letter queue for replay

D The registry accepts it on the second attempt

Q5

#### A schema change is fully compatible. Why must the sink still be idempotent and ordered by source log position?

A Because compatibility modes are only advisory

B Because a compatible schema makes the registry resend each event

C Compatibility covers an event's shape, not delivery: a change can still arrive twice or late

D It does not; full compatibility removes duplicates

**0/5** correct

## Further resources

- [Schema Evolution](https://sandgraal.github.io/letstalkcdc/schema-evolution/): registry modes, subject naming, rollouts, consumer defenses.
- [Event Envelope: schema contracts and validation](https://sandgraal.github.io/letstalkcdc/event-envelope/#schema), and [Strategy](https://sandgraal.github.io/letstalkcdc/strategy/) for where contracts sit in an operating model.
- [Testing a CDC pipeline: contract tests](https://sandgraal.github.io/letstalkcdc/test-your-pipeline/#contract), and [Is CDC exactly-once, per hop](https://sandgraal.github.io/letstalkcdc/is-cdc-exactly-once/).
- Glossary: [schema registry](https://sandgraal.github.io/letstalkcdc/glossary/#schema-registry), [schema evolution](https://sandgraal.github.io/letstalkcdc/glossary/#schema-evolution), [SMT](https://sandgraal.github.io/letstalkcdc/glossary/#smt), [outbox](https://sandgraal.github.io/letstalkcdc/glossary/#outbox), [tombstone](https://sandgraal.github.io/letstalkcdc/glossary/#tombstone), [dead letter queue](https://sandgraal.github.io/letstalkcdc/glossary/#dead-letter-queue), [at-least-once](https://sandgraal.github.io/letstalkcdc/glossary/#at-least-once), [convergence test](https://sandgraal.github.io/letstalkcdc/glossary/#convergence-test).
- [Confluent: Schema evolution and compatibility](https://docs.confluent.io/platform/current/schema-registry/fundamentals/schema-evolution.html): types, the Avro and Protobuf table, transitive, upgrade order.
- [Confluent: Data contracts](https://docs.confluent.io/platform/current/schema-registry/fundamentals/data-contracts.html): what a contract contains, rules, requirements.
- [Confluent: Schema Registry API reference](https://docs.confluent.io/platform/current/schema-registry/develop/api.html): the compatibility and config endpoints, the 409 on register.
- [Confluent: Schema Registry and Kafka Connect](https://docs.confluent.io/platform/current/schema-registry/connect.html): `auto.register.schemas`, `use.latest.version`.
- [Confluent: Kafka Connect error handling and dead letter queues](https://www.confluent.io/blog/kafka-connect-deep-dive-error-handling-dead-letter-queues/).
- [Kafka Connect user guide](https://kafka.apache.org/41/kafka-connect/user-guide/): error reporting in Connect.
- [Avro 1.12.0 specification](https://avro.apache.org/docs/1.12.0/specification/): schema resolution, aliases.
- [Debezium PostgreSQL connector](https://debezium.io/documentation/reference/stable/connectors/postgresql.html), [MySQL](https://debezium.io/documentation/reference/stable/connectors/mysql.html) and [SQL Server](https://debezium.io/documentation/reference/stable/connectors/sqlserver.html): events, schema history, schema changes.
- [Debezium event flattening](https://debezium.io/documentation/reference/stable/transformations/event-flattening.html) and [outbox event router](https://debezium.io/documentation/reference/stable/transformations/outbox-event-router.html).
- [Debezium: Avro serialization](https://debezium.io/documentation/reference/stable/configuration/avro.html).

---

CDC: The Missing Manual by Christopher Ennis (Let's Talk CDC), licensed under CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). Attribution is required.
