KafkaIQ

10.3.3 – Versioning Rules and Migration Strategies

Module: Section 10 – Kafka Ecosystem & Tools
Section: 10.3 – Schema Evolution & Compatibility
Goal: Master schema versioning best practices and learn how to handle complex schema migrations


Introduction

Schema versioning is more than just incrementing version numbers—it requires strategic planning, coordination, and migration strategies for complex changes.

Why This Module Matters: Poor versioning strategies lead to production outages, data loss, and deployment coordination nightmares. This module teaches you how to evolve schemas safely.


Schema Version Basics

Schema ID vs Schema Version

Subject: "users-value"

Version 1 → Schema ID 5   (First registration for this subject)
Version 2 → Schema ID 12  (Second registration for this subject)
Version 3 → Schema ID 27  (Third registration for this subject)

Key Difference:

  • Schema Version: Scoped to subject (1, 2, 3, ...)
  • Schema ID: Global across all subjects (never reused, auto-incremented)

Message Format

Every Kafka message includes the global schema ID:

[0x00] [0x00 0x00 0x00 0x0C] [binary_data]
 ↑      ↑                     ↑
Magic   Schema ID = 12       Avro-encoded data

Consumer Workflow:

  1. Extract schema ID (12)
  2. GET /schemas/ids/12 from Schema Registry
  3. Use schema to deserialize data

Versioning Best Practices

1. Semantic Versioning for Schemas

Apply semver principles:

MAJOR.MINOR.PATCH

1.0.0: Initial schema
1.1.0: Add optional field (backward compatible)
1.2.0: Add another optional field (backward compatible)
1.3.0: Deprecate field (backward compatible)
2.0.0: Remove field or breaking change

Implementation (via doc field):

{
  "name": "User",
  "namespace": "com.example.v2",  // ← Version in namespace
  "doc": "User schema v2.0.0",
  "fields": [...]
}

2. Use Namespace Versioning

// v1
{"namespace": "com.example.v1", "name": "User"}

// v2 (breaking change)
{"namespace": "com.example.v2", "name": "User"}

Benefits:

  • Clear version boundary
  • Code generation creates separate classes
  • No ambiguity in multi-version systems

3. Document Schema Changes

{
  "name": "email",
  "type": ["null", "string"],
  "default": null,
  "doc": "User email address. Added in v1.2.0 (2025-02-01). Required for marketing campaigns."
}

Why?: Future developers (including yourself) need context for schema decisions.


4. Deprecate Before Removing

// v1: Field in use
{"name": "legacyField", "type": "string"}

// v2: Mark deprecated
{
  "name": "legacyField",
  "type": ["null", "string"],
  "default": null,
  "doc": "DEPRECATED (v2.0.0): Use newField instead. Will be removed in v3.0.0"
}

// v3: Remove field (6 months later)
// (field gone)

Grace Period: Give consumers time to migrate (e.g., 6-12 months).


5. Test Schema Evolution Locally

# Step 1: Register v1 schema
curl -X POST http://localhost:8081/subjects/users-value/versions \
  -d '{"schema": "..."}'

# Step 2: Test v2 compatibility
curl -X POST http://localhost:8081/compatibility/subjects/users-value/versions/latest \
  -d '{"schema": "..."}'

# Step 3: If compatible, register v2
curl -X POST http://localhost:8081/subjects/users-value/versions \
  -d '{"schema": "..."}'

Handling Breaking Changes

Strategy 1: Dual-Write Migration

Problem: Need to rename field userId → customerId (breaking change).

Solution: Dual-write period.


Phase 1: Add New Field (v2)

{
  "fields": [
    {"name": "userId", "type": "long"},
    {"name": "customerId", "type": "long"}  // New field, same value
  ]
}

Producer Code:

User user = User.newBuilder()
    .setUserId(123L)
    .setCustomerId(123L)  // Duplicate data temporarily
    .build();

Duration: 3-6 months (or retention period).


Phase 2: Deprecate Old Field (v3)

{
  "fields": [
    {
      "name": "userId",
      "type": ["null", "long"],
      "default": null,
      "doc": "DEPRECATED: Use customerId"
    },
    {"name": "customerId", "type": "long"}
  ]
}

Consumer Code:

// New consumers migrate
long id = user.getCustomerId();  // Use new field

// Old consumers still work
long id = user.getUserId() != null ? user.getUserId() : user.getCustomerId();

Duration: 3-6 months.


Phase 3: Remove Old Field (v4)

{
  "fields": [
    {"name": "customerId", "type": "long"}
  ]
}

Validation: Ensure no consumers read userId before removing.


Strategy 2: Topic Migration

Problem: Breaking change that can't be handled via dual-write.

Solution: Create new topic with new schema, migrate data.


Step 1: Create New Topic

kafka-topics.sh --create --topic users-v2 \
  --partitions 10 \
  --replication-factor 3 \
  --config retention.ms=86400000

Step 2: Dual-Write to Both Topics

// Producer writes to both topics
producer.send(new ProducerRecord<>("users", user));     // v1 schema
producer.send(new ProducerRecord<>("users-v2", userV2)); // v2 schema

Duration: Until all consumers migrate.


Step 3: Migrate Consumers

// Consumer switches to new topic
consumer.subscribe(Collections.singletonList("users-v2"));

Step 4: Stop Writing to Old Topic

// Producer only writes to new topic
producer.send(new ProducerRecord<>("users-v2", userV2));

Step 5: Decommission Old Topic

# After retention period expires
kafka-topics.sh --delete --topic users

Strategy 3: Schema Aliasing (Avro Feature)

Problem: Renamed field breaks compatibility.

Solution: Use Avro aliases.


// v1
{"name": "userId", "type": "long"}

// v2 (rename with alias)
{
  "name": "customerId",
  "type": "long",
  "aliases": ["userId"]  // ← Maintains backward compatibility
}

Result:

  • ✅ v2 consumer can read v1 data (uses alias mapping)
  • ✅ v1 consumer can't read v2 data (still breaking forward compatibility)

Use Case: Backward compatibility only (common with consumer-first deployments).


Advanced Migration Patterns

Pattern 1: Expand-Contract (a.k.a. Parallel Change)

Steps:

  1. Expand: Add new field alongside old field
  2. Migrate: Consumers switch to new field
  3. Contract: Remove old field

Timeline:

Week 1:  Add customerId (alongside userId)
Week 2-8: Producers write both, consumers read customerId
Week 9:  Remove userId

Pattern 2: Strangler Fig Pattern

Use Case: Gradual migration for complex systems.

Steps:

  1. New service writes to new topic (new schema)
  2. Old service continues writing to old topic
  3. Gradually route traffic from old to new service
  4. Decommission old service

Example:

Old: OrderServiceV1 → orders topic (v1 schema)
New: OrderServiceV2 → orders-v2 topic (v2 schema)

Traffic: 10% → v2, 90% → v1
         50% → v2, 50% → v1
         100% → v2, 0% → v1

Pattern 3: Blue-Green Schema Deployment

Use Case: Zero-downtime migration for critical topics.

Steps:

  1. Blue (current): users topic with v1 schema
  2. Green (new): users-green topic with v2 schema
  3. Dual-write to both topics
  4. Switch consumers to green
  5. Promote green → blue, decommission old blue

Versioning Anti-Patterns

❌ Anti-Pattern 1: Removing Fields Without Deprecation

// v1
{"name": "email", "type": "string"}

// v2 (remove immediately) ← BAD!
// (field gone)

Problem: Consumers crash if they expect email.

Solution: Deprecate for 6-12 months first.


❌ Anti-Pattern 2: Changing Field Type Without Migration Path

// v1
{"name": "age", "type": "int"}

// v2 (change type) ← BAD!
{"name": "age", "type": "string"}

Problem: Incompatible types, consumers can't deserialize.

Solution: Add new field, deprecate old field.


❌ Anti-Pattern 3: No Default Values for New Fields

// v2 (add required field) ← BAD!
{"name": "newField", "type": "string"}  // No default!

Problem: v2 consumer can't read v1 data (missing newField).

Solution: Always provide defaults for new fields.


❌ Anti-Pattern 4: Breaking Changes Without Communication

Developer: *Pushes v2 schema with breaking change*
            ↓
Schema Registry: ❌ Rejected (incompatible)
            ↓
Developer: *Sets compatibility to NONE*
            ↓
20 microservices crash in production 🔥

Solution: Breaking changes require cross-team coordination, migration plan, and approval.


Schema Registry Versioning Controls

Temporarily Disable Compatibility

Scenario: Emergency breaking change needed.

# 1. Temporarily disable compatibility
curl -X PUT http://localhost:8081/config/users-value \
  -d '{"compatibility": "NONE"}'

# 2. Register breaking schema
curl -X POST http://localhost:8081/subjects/users-value/versions \
  -d '{"schema": "..."}'

# 3. Re-enable compatibility
curl -X PUT http://localhost:8081/config/users-value \
  -d '{"compatibility": "BACKWARD"}'

⚠️ Use with extreme caution! Requires full coordination with consumers.


Delete Schema Version (Soft Delete)

# Soft delete version 3
curl -X DELETE http://localhost:8081/subjects/users-value/versions/3

Effect: Version marked as deleted, not returned in listings.

Permanent Delete:

curl -X DELETE "http://localhost:8081/subjects/users-value/versions/3?permanent=true"

⚠️ Warning: Permanent delete breaks consumers that have messages with that schema ID!


Migration Checklist

Before Schema Change

  • Test compatibility locally
  • Check compatibility mode for subject
  • Document change in schema doc field
  • Notify consuming teams
  • Plan rollback strategy

During Migration

  • Register new schema via CI/CD (not manually)
  • Monitor producer/consumer errors
  • Check Schema Registry metrics (registration failures)
  • Verify messages with new schema are readable

After Migration

  • Update schema documentation
  • Remove deprecated code after grace period
  • Update schema evolution runbook
  • Post-mortem if issues occurred

Key Takeaways

  1. Schema Version: Scoped to subject (1, 2, 3). Schema ID: Global, never reused.

  2. Semantic Versioning: Use MAJOR.MINOR.PATCH for schema versions.

  3. Breaking Changes: Require dual-write, topic migration, or expand-contract pattern.

  4. Dual-Write: Add new field, write both, migrate consumers, remove old field.

  5. Topic Migration: Create new topic, dual-write, migrate consumers, decommission old topic.

  6. Aliases: Avro feature for backward-compatible field renames.

  7. Deprecation: Mark fields deprecated 6-12 months before removing.

  8. Anti-Patterns: No defaults, immediate removal, type changes without migration.

  9. Emergency: Temporarily disable compatibility only with full coordination.

  10. Checklist: Test, document, notify, monitor, rollback plan.


What's Next?

Next Module: 10.3.4 – Real-World Evolution Scenarios

What We Covered:

  • ✅ Schema versioning best practices
  • ✅ Handling breaking changes
  • ✅ Migration strategies
  • ✅ Anti-patterns

What's Coming:

  • ❌ Real-world evolution examples
  • ❌ Complex migration case studies
  • ❌ Production lessons learned

References:

  • Schema evolution → 10.3.1: Schema Evolution Concepts
  • Compatibility modes → 10.3.2: Compatibility Types
  • Operations → 10.4: Schema Registry Operations