Skip to content

AxonOps — AI-Native Control Plane for Open Source Data Platforms

Schema Evolution

Schema evolution allows schemas to change over time while maintaining compatibility with existing data and applications.


Schema Registry uses the concepts of writer schema (used when data was written) and reader schema (used when reading data):

Writer schema and reader schema resolution through the registryWriter schema and reader schema resolution through the registryProducerKafka TopicConsumerWriter Schema v2Message(schema ID: 2)Reader Schema v3Message stores schema IDRegistry resolves schema for deserializationWrite with v2Read with v3

When reader and writer schemas differ, serialization frameworks apply resolution rules:

ScenarioResolution
Field in writer, not in readerField ignored
Field in reader with default, not in writerDefault value used
Field in reader without default, not in writerError
Type mismatchError (unless promotable)

Adding optional fields with defaults is safe for all compatibility modes:

Avro:

{
"type": "record",
"name": "User",
"fields": [
{"name": "id", "type": "long"},
{"name": "name", "type": "string"},
{"name": "email", "type": ["null", "string"], "default": null}
]
}

Protobuf:

message User {
int64 id = 1;
string name = 2;
optional string email = 3; // Added field
}

JSON Schema:

{
"type": "object",
"properties": {
"id": {"type": "integer"},
"name": {"type": "string"},
"email": {"type": "string"}
},
"required": ["id", "name"]
}

Removing optional fields is safe when using FORWARD or FULL compatibility:

// Version 1
{
"type": "record",
"name": "User",
"fields": [
{"name": "id", "type": "long"},
{"name": "name", "type": "string"},
{"name": "nickname", "type": ["null", "string"], "default": null}
]
}
// Version 2 (nickname removed)
{
"type": "record",
"name": "User",
"fields": [
{"name": "id", "type": "long"},
{"name": "name", "type": "string"}
]
}

Old consumers (v1) reading new data (v2) will use the default value for nickname.


Adding a required field without a default breaks backward compatibility:

// Version 1
{
"fields": [
{"name": "id", "type": "long"}
]
}
// Version 2 - INCOMPATIBLE
{
"fields": [
{"name": "id", "type": "long"},
{"name": "created_at", "type": "long"} // No default!
]
}

Old data lacks created_at, so new consumers cannot deserialize it.

Type changes are incompatible:

// Version 1
{"name": "user_id", "type": "long"}
// Version 2 - INCOMPATIBLE
{"name": "user_id", "type": "string"}

Direct field renames are incompatible without aliases:

// Version 1
{"name": "user_name", "type": "string"}
// Version 2 - INCOMPATIBLE (without alias)
{"name": "username", "type": "string"}

Pattern 1: Add with Default, Then Remove Default

Section titled “Pattern 1: Add with Default, Then Remove Default”

For fields that should eventually be required:

// Step 1: Add optional field with default
{"name": "email", "type": ["null", "string"], "default": null}
// Wait for all producers to populate email
// Step 2: Remove default (field still optional)
{"name": "email", "type": ["null", "string"]}
// Wait for all historical data without email to expire
// Step 3: Make required (only if all data has email)
{"name": "email", "type": "string"}

For field removal:

Four-phase deprecation sequence for removing a fieldFour-phase deprecation sequence for removing a fieldPhase 1Add new fieldPhase 2Producers migratePhase 3Consumers migratePhase 4Remove old fieldold_fieldnew_fieldold_field(deprecated)new_field(primary)old_field(read-only)new_fieldnew_field

Rename fields safely with Avro aliases:

{
"type": "record",
"name": "User",
"fields": [
{
"name": "username",
"type": "string",
"aliases": ["user_name", "userName"]
}
]
}

Old data with user_name will be read into username.

Use union types for fields that may have multiple formats:

{
"name": "timestamp",
"type": [
"null",
"long",
{"type": "string", "logicalType": "timestamp-millis"}
],
"default": null
}

New schema must read old data:

ChangeAllowed
Add optional field with default
Add required field
Remove field
Widen type (int → long)
Narrow type (long → int)

Upgrade order: Consumers first, then producers.

Old schema must read new data:

ChangeAllowed
Add field
Remove optional field with default
Remove required field
Widen type (int → long)
Narrow type (long → int)

Upgrade order: Producers first, then consumers.

Both backward and forward compatible:

ChangeAllowed
Add optional field with default
Remove optional field with default
Add required field
Remove required field
Change type

Upgrade order: Any order.


Each schema registration creates a version:

Terminal window
# List versions
curl http://schema-registry:8081/subjects/users-value/versions
# [1, 2, 3]
# Get specific version
curl http://schema-registry:8081/subjects/users-value/versions/2

Schema IDs are global and unique across all subjects:

SchemaSubjectVersionGlobal ID
User v1users-value11
Order v1orders-value12
User v2users-value23

Soft delete removes schema from listing but preserves ID:

Terminal window
# Soft delete version
curl -X DELETE http://schema-registry:8081/subjects/users-value/versions/2
# Hard delete (after soft delete)
curl -X DELETE http://schema-registry:8081/subjects/users-value/versions/2?permanent=true

Schema Deletion

Deleting schemas can break consumers holding cached references. Only delete schemas when certain no data exists with that schema ID.


Terminal window
# Test compatibility before registering
curl -X POST \
-H "Content-Type: application/vnd.schemaregistry.v1+json" \
--data '{"schema": "{...}"}' \
http://schema-registry:8081/compatibility/subjects/users-value/versions/latest
# Response
{"is_compatible": true}
compatibility-check.sh
#!/bin/bash
SCHEMA=$(cat schema.avsc | jq -Rs '.')
RESULT=$(curl -s -X POST \
-H "Content-Type: application/vnd.schemaregistry.v1+json" \
--data "{\"schema\": $SCHEMA}" \
"$SCHEMA_REGISTRY_URL/compatibility/subjects/$SUBJECT/versions/latest")
if echo "$RESULT" | jq -e '.is_compatible == true' > /dev/null; then
echo "Schema is compatible"
exit 0
else
echo "Schema is INCOMPATIBLE"
echo "$RESULT" | jq '.messages'
exit 1
fi

PracticeRationale
Start with BACKWARDMost common upgrade pattern (consumers first)
Always provide defaultsEnables safe field addition
Use optional fieldsAllows both addition and removal
Avoid type changesTypes should be permanent
Document field semanticsPrevent misuse across versions
Use semantic versioningTrack breaking vs non-breaking changes
Test in stagingValidate compatibility before production