Data contracts — the column rename that didn't take down prod
A versioned schema agreement between producer and consumer, enforced in CI. The lesson where `column rename` becomes a tracked event, not a 2 AM page.
What is a contract
A data contract is a versioned, machine-readable schema that the producer of a table publishes and the consumer relies on. It states: "this table will have these columns, with these types, this nullability, and these invariants — and we will tell you (and bump the version) before we change it."
It's the same idea as a REST API contract or a gRPC .proto file — applied to tables. The point isn't to prevent changes; it's to make changes visible and give consumers time to adapt.
Producer / contract / consumer / CI
Three relationships matter:
- Producer ↔ contract: the producer owns the contract. Any code change that touches the schema must update the contract in the same PR.
- Consumer ↔ contract: the consumer reads the table through the contract. If a column it relies on is missing in the YAML, the consumer's CI fails before deploy.
- PR ↔ CI gate: the gate compares the live schema to the YAML on every PR. Breaking changes (rename, type change, drop) fail unless the contract version is bumped — which itself requires consumer sign-off in the social half of the contract.
# contracts/oltp/orders.yml
table: orders
schema: oltp
owner: backend-team@codabra.app
version: 3 # bump when the schema changes
columns:
- name: order_id
type: bigint
nullable: false
primary_key: true
- name: customer_id
type: bigint
nullable: false
references: customers.customer_id
- name: status
type: text
nullable: false
accepted_values: [pending, paid, refunded, cancelled]
- name: total_cents
type: integer
nullable: false
invariant: ">= 0"
- name: created_at
type: timestamptz
nullable: false
freshness:
max_lag_minutes: 15 # producer commits to this
breaking_changes_require: 14_days_notice # the social half of the contractHow the contract becomes load-bearing
A contract sitting in a YAML file is a wish. It becomes load-bearing when CI checks two things on every PR that touches the producer's code:
- Schema diff: actual schema (from
information_schema.columns) matches the contract. Adding a column withnullable: trueis a non-breaking additive change. Removing a column, renaming, or changing a type is breaking — fail CI unless the contract version is bumped and the consumer side has been notified. - Behavioral checks at runtime:
accepted_values,invariant,freshnessSLO. These run nightly; alert (not block) on failures.
The rename outage from Module 00 is exactly this: with a contract, the rename PR fails CI; without one, it ships and breaks dashboards at 2 AM.
Implement an `accepted_values` check on `orders.status`. The contract says status must be in `('pending', 'paid', 'refunded', 'cancelled')`. Return one row, two columns: `bad_rows` (count of rows with status outside the set) and `passes` (true when bad_rows = 0).
Takeaway: a contract is a versioned schema published by the producer, checked by CI on every change, and enforced by behavioral tests at runtime. With it, schema changes become tracked events. Without it, they become 2 AM pages.