Logical Types
Schema Registry supports Logical Types, which let you represent a schema using SQL types, not the syntax of a serialization format. For example, this Avro schema:
{
"type": "record",
"name": "Person",
"fields": [
{"name": "id", "type": "string"},
{"name": "name", "type": "string"},
{"name": "age", "type": ["null", "int"]},
{"name": "active", "type": "boolean"}
]
}
becomes this Logical Type:
STRUCT Person (
id STRING NOT NULL,
name STRING NOT NULL,
age INT,
active BOOLEAN NOT NULL
)
Register the Logical Type the same way as any other schema:
curl -X POST -H "Content-Type: application/json" \
--data '{
"schemaType": "AVRO",
"schema": "STRUCT Person ( id STRING NOT NULL, name STRING NOT NULL, age INT, active BOOLEAN NOT NULL )"
}' \
'http://localhost:8081/subjects/mytopic-value/versions'
schemaType still must be AVRO, PROTOBUF, or JSON, because
this indicates which serialization format clients use. If you retrieve the
preceding schema, it matches the Avro form shown earlier. Specifying
PROTOBUF instead returns:
syntax = "proto3";
message Person {
string id = 1;
string name = 2;
optional int32 age = 3;
bool active = 4;
}
And specifying JSON returns:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"additionalProperties": false,
"properties": {
"id": {"type": "string"},
"name": {"type": "string"},
"age": {"oneOf": [{"type": "null"}, {"type": "number"}]},
"active": {"type": "boolean"}
},
"required": ["id", "name", "active"],
"title": "Person",
"type": "object"
}
Retrieve a schema in Logical Type form
To retrieve the Logical Type form of a schema, pass ?format=LOGICAL on
the request:
curl -H "Content-Type: application/json" \
'http://localhost:8081/subjects/mytopic-value/versions/1?format=LOGICAL'
Every endpoint that returns a schema accepts the format parameter, not
only version reads:
Registering a schema
Subject and version lookups
The by-ID endpoint under
/schemasThe by-GUID endpoint under
/schemas
Input works the other way: Schema Registry detects a Logical Type from the request body.
It first tries to parse the content as the declared schemaType and falls
back to Logical Type syntax only if that parse fails. Because detection is
content-driven, you can send a Logical Type anywhere you can send a schema,
including the compatibility endpoints for a single version
(/compatibility/subjects/<subject>/versions/<version>) and for all
versions (/compatibility/subjects/<subject>/versions). This flexibility lets you check
whether Schema Registry would accept a Logical Type before you register it:
curl -X POST -H "Content-Type: application/json" \
--data '{
"schemaType": "AVRO",
"schema": "STRUCT Person ( id STRING NOT NULL, name STRING NOT NULL, age BIGINT, active BOOLEAN NOT NULL )"
}' \
'http://localhost:8081/compatibility/subjects/mytopic-value/versions/latest'
Namespaces, ENUM, and nested STRUCT
Logical Types can represent most of what any of the serialization formats
can express. This example declares a NAMESPACE, an ENUM, and two
STRUCT types, one of which references the other:
NAMESPACE com.example.demo;
ENUM Status ('ACTIVE', 'PAUSED', 'CANCELLED');
STRUCT Address (
street STRING NOT NULL,
city STRING NOT NULL,
postal_code VARCHAR(16)
);
STRUCT Order (
id STRING NOT NULL,
retries INT NOT NULL DEFAULT 0,
status Status NOT NULL,
shipping Address,
billing Address
);
An ENUM symbol is a quoted string literal, so the grammar itself
allows any character. Schema Registry doesn’t check symbol characters against
the target format at parse time. It passes each symbol straight to
that format’s own schema builder, which enforces its own naming
rules. Avro and Protobuf both require symbols to be valid identifiers,
so a symbol like 'in-progress' fails at registration for either
one. JSON Schema has no such restriction.
For Protobuf, symbols number in declaration order starting at 0.
To set a symbol’s number explicitly, add a WITH clause after that
symbol, for example
ENUM Status ('UNKNOWN' WITH ('protobuf.enum.number' = '0'), 'ACTIVE' WITH ('protobuf.enum.number' = '1')).
proto3 requires the first declared symbol to be number 0, and every
number must be unique. Number 0 is also proto3’s default value for an
unset enum field. An unset field then silently reads as the symbol
numbered 0, ACTIVE in the Order example’s Status, even
though status is NOT NULL. Declare a sentinel symbol first,
such as 'UNKNOWN', if that default matters to readers of the data.
Because numbering follows declaration order, inserting, removing, or
reordering symbols renumbers every symbol after the change. Existing
Protobuf-encoded data then decodes as different symbols. Add new
symbols only at the end and keep existing ones, or number every
symbol explicitly. This also applies to a sentinel symbol: declare it
in the first version, because adding it first to an existing ENUM
renumbers every other symbol.
When a script declares more than one named type, Schema Registry infers the root
schema as a declared type that no other declared type references. In
this example, that’s Order, because Status and Address are
both referenced by it. When more than one declared type has no
incoming references, the first one declared becomes the root. The
rest remain as peer named types in Protobuf and JSON Schema, which
both support more than one named type per schema. Avro
allows only one top-level type, so the Avro output entirely drops a
declared type that the root doesn’t reference. When every declared
type has an incoming reference from another declared type, forming a
cycle, Schema Registry can’t infer a root and rejects the script.
To set the root explicitly instead of relying on this inference, add a
trailing TYPE Order NOT NULL; statement after the declarations. If
the inferred root is an ENUM, registering it for Protobuf fails,
because Protobuf has no message-less root. Use the ENUM as a field
of a STRUCT and let that STRUCT be the root, by inference or
with TYPE.
This Order example corresponds to the following Avro schema. The
Avro form is more verbose, and nullable fields translate to unions, as
Avro requires:
{
"type": "record",
"name": "Order",
"namespace": "com.example.demo",
"fields": [
{"name": "id", "type": "string"},
{"name": "retries", "type": "int", "default": 0},
{"name": "status", "type": {
"type": "enum", "name": "Status",
"symbols": ["ACTIVE", "PAUSED", "CANCELLED"]
}},
{"name": "shipping", "type": ["null", {
"type": "record", "name": "Address",
"fields": [
{"name": "street", "type": "string"},
{"name": "city", "type": "string"},
{"name": "postal_code", "type": ["null",
{"type": "string", "flink.maxLength": 16}]}
]
}]},
{"name": "billing", "type": ["null", "Address"]}
]
}
Logical Types can be user-defined (Status, Order, and Address
in the preceding example, using STRUCT or ENUM) or built-in,
corresponding to SQL types
such as INT. To register a built-in type on its own, such as when using
a simple integer as a message key’s schema, use TYPE:
TYPE INT NOT NULL
This declaration corresponds to {"type": "int"} in Avro. Types are
nullable by default, so specify NOT NULL to get a non-nullable type.
Without it, TYPE INT corresponds to ["null", "int"].
Logical Types can also express unions:
STRUCT IdAndPayload (
id STRING NOT NULL,
payload UNION (
text STRING NOT NULL,
number BIGINT NOT NULL,
blob BINARY(16) NOT NULL
) NOT NULL
);
A UNION translates to an Avro union, a Protobuf oneof, or a JSON
Schema oneOf. Every branch in the preceding example is NOT
NULL because a nullable branch would become a nested union, and
Avro doesn’t support that. Avro also rejects a union with two unnamed
branches of the same Avro type. Each branch must map to a distinct
Avro type, not only use a distinct name or Logical Type. For
example, INT, SMALLINT, and DATE all map to Avro int,
and CHAR(n), VARCHAR(n), and STRING all map to
string. The same rule covers collections, whatever their element
types. An Avro union can hold at most one array and one map.
ARRAY<T>, MULTISET<T>, and a MAP<K,V> with a non-string
key all become Avro arrays, so any two of them conflict. For Avro,
Schema Registry rejects such a schema at registration. It doesn’t silently
collapse or rename a branch.
JSON Schema has no matching check. Two branches that map to the
same JSON type, such as text STRING and blob BINARY(16) in the
preceding example (both become string), register without error.
oneOf passes only when exactly one branch matches a value, so a
JSON Schema validator rejects every string value for that UNION,
because a string always matches both branches. number values, such
as the number BIGINT branch, still validate. The confluent:union
branch names in the schema don’t prevent this. When you register a
UNION as JSON, make sure each branch maps to a distinct JSON
type.
The UNION itself can also be nullable, unlike its branches. For
example, payload UNION (...) without NOT NULL doesn’t nest a
["null", [...]] union, which Avro forbids. Schema Registry splices null
into the existing branch list instead, producing
["null", "string", "long", {"type": "fixed", "name": "blob", "size":
16}] for the preceding example.
A BINARY(n) branch, such as blob in the example, becomes an
Avro fixed type named after the branch, here blob. This name
isn’t qualified by the enclosing struct or union. If two different
unions in the same schema each declare a same-named BINARY(n)
branch with different sizes, Schema Registry rejects the schema at registration.
The two conflict under the same Avro fixed-type name.
Schema references for Logical Types
Logical Types can refer to other named types. Schema Registry stores every Logical
Type in its converted native form, the same way it stores any other
schema, so the referenced schema must use the same schemaType as
the referencing one. It can be registered either as a Logical Type or
as a native schema of that type. If a type name isn’t built-in
or locally defined as a STRUCT or ENUM, Schema Registry treats it as a reference
to an external Logical Type. This also applies to a misspelled built-in
or local type name, for example BIGIN for BIGINT. Schema Registry doesn’t
reject it as an unknown type. It treats the misspelling as an external
reference, so it fails with a missing- or unresolved-reference error.
If you see that error on a name you expected Schema Registry to treat as built-in
or local, check it for a typo before adding a schema reference.
Register the Logical Type with schema
references to the external types:
curl -X POST -H "Content-Type: application/json" \
--data '{
"schemaType": "AVRO",
"schema": "STRUCT Order ( id STRING NOT NULL, quantity INT NOT NULL, ship_to com.example.demo.Address NOT NULL )",
"references": [
{"name": "com.example.demo.Address", "subject": "address", "version": 1}
]
}' \
'http://localhost:8081/subjects/orders-value/versions'
The simplest reference is a bare reference:
TYPE com.example.demo.Address NOT NULL
Use TYPE here, not STRUCT, because STRUCT only defines a new
user-defined type. This TYPE reference points to a STRUCT already
defined elsewhere. Types are nullable by default here too, so specify
NOT NULL to get a non-nullable reference. Register it the same way
as any other Logical Type, including the references array. For
Avro, a bare reference
resolves to the string
"com.example.demo.Address". For Protobuf, it resolves to an
import public statement, an otherwise-empty file that re-exports
the referenced message. Schema Registry resolves through the import public
when reading the schema back, the same way a Protobuf serializer or
deserializer does, so the bare reference works as a value or key
schema. For JSON Schema, it resolves to a $ref pointing into
$defs.
Struct and field clauses
Structs and fields can carry the following clauses. NOT NULL also
applies to any other type position: standalone TYPE declarations, a
UNION and each of its branches, and collection element, key, and
value types. DEFAULT applies only to fields. The rest apply to
both structs and fields:
NOT NULL: marks the value as required, disallowing nullDEFAULT: field only, a default value for primitive typesCHECK: a struct-level or field-level constraint. Write a field-levelCHECKafter the field’s type. Write a struct-levelCHECKas its own comma-separated entry inside the struct’s parentheses, not after them withCOMMENT,TAGS, andWITH. Optionally follow either form withMESSAGE '<text>'to set the error message returned when the constraint failsCOMMENT: a human-readable description of a struct or fieldTAGS: labels used to select structs or fields for rule targeting, such asPIIWITH: key-value parameters for struct- or field-level metadata
DEFAULT becomes the native default field for Avro and JSON
Schema. proto3 has no field defaults, so for Protobuf, DEFAULT
becomes a connect.default parameter under (confluent.field_meta),
letting ?format=LOGICAL restore it. This parameter is metadata only.
A Protobuf deserializer still reads an unset field as the type’s zero
value, such as 0 for INT, not as the declared default.
For Avro, a nullable field with no DEFAULT clause gets no
default at all, not an implicit null default, as the
preceding examples show. Adding such a field in a later version
breaks BACKWARD compatibility, because Avro schema resolution has
nothing to fill in for old data. When you add a nullable field in a
later version, give it DEFAULT NULL.
COMMENT becomes the native doc field or comment for the target
format. For Avro and JSON Schema, TAGS becomes a confluent:tags
property and WITH parameters become a confluent:params
property. For Protobuf, both become .tags and .params
sub-fields of the (confluent.field_meta) option on a field, or of
the (confluent.message_meta) option on a struct. Format-native
keys are the exception: any key prefixed avro. or protobuf.,
such as avro.aliases. For the format they name, they map to a
native attribute, as in the ssn field’s alias in the following
example. For every other format, Schema Registry drops them. They never appear
as an ordinary parameter. Schema Registry also reserves any key prefixed
connect., flink., or logical., such as
connect.default, for the same reason but a different mechanism:
it populates them itself to round-trip a value it already stores
structurally, such as a field’s default. Setting one directly in
WITH isn’t supported.
CHECK compiles to a CEL expression and becomes a
domain rule in the schema’s ruleSet. Schema Registry doesn’t run this check at
registration, and it never sees record data. The client serializer or
deserializer enforces the rule when it runs data contract rules. A
client that ignores ruleSet, such as an older serializer or a
non-Confluent client, writes data that violates the constraint with no
error.
Because CHECK produces a schema rule, it’s subject to the same
requirements as any other rule. Schema Registry must run on Confluent Enterprise with
resource.extension.class=io.confluent.kafka.schemaregistry.rulehandler.RuleSetResourceExtension
set. Clients run rules only for the root schema, not for referenced
schemas. For example, the CHECK constraints on Person in the
following example don’t run when you serialize the Employee type
shown in the next section, which references Person. Flink SQL
doesn’t run rules.
A field-level CHECK’s compiled rule skips evaluation entirely when
that field’s value is null or absent, so a null value always passes,
the same outcome as a SQL CHECK on a NULL column. The ssn
and age checks in the following example never run against a null
value, so pair a field-level CHECK with a struct-level one, such as
age IS NOT NULL OR ssn IS NOT NULL, to require at least one of
them to be present.
NAMESPACE com.example.common;
STRUCT Person (
id BIGINT NOT NULL
COMMENT 'Primary key',
ssn STRING
CHECK (MATCHES(ssn, '^[0-9]{3}-[0-9]{2}-[0-9]{4}$'))
MESSAGE 'ssn must look like 123-45-6789'
COMMENT 'US social security number'
TAGS ('PII')
WITH ('avro.aliases' = 'social_security_number'),
email STRING
COMMENT 'Contact address'
TAGS ('PII', 'CONTACT'),
age INT
CHECK (age BETWEEN 0 AND 150),
CHECK (age IS NOT NULL OR ssn IS NOT NULL) MESSAGE 'need age or ssn'
)
COMMENT 'A person record'
TAGS ('INTERNAL')
WITH ('owner' = 'hr-team', 'retention.days' = '365');
Local aliases with USING
A USING directive declares a local alias for a type name. A short
alias improves readability when a user-defined type isn’t in the
current namespace:
NAMESPACE com.example.hr;
USING TYPE Person FOR com.example.common.Person;
STRUCT Employee (
id BIGINT NOT NULL,
person Person
);
Person isn’t built-in or locally defined as a STRUCT or
ENUM in this script, so it’s still an external reference.
USING only shortens the name used in the syntax. You still add
com.example.common.Person to the references array when you
register this schema.
Logical Type mappings
Avro also has its own, unrelated logicalType schema attribute,
which the following table uses to encode some Logical Types. The
table shows how built-in Logical Types map to Avro, Protobuf, and
JSON Schema.
Logical Type |
Avro |
Protobuf |
JSON Schema |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Array of |
|
Array of key-value objects |
|
|
|
|
|
|
Nested message, for example |
|
|
Avro union, for example |
|
|
|
|
|
|
|
Named record |
|
Top-level |
|
|
|
Top-level schema using |
The JSON Schema column uses number for every integer and temporal
Logical Type, including TINYINT through BIGINT, DATE,
TIME(n), and the TIMESTAMP types. JSON Schema validation
against number doesn’t reject a fractional value, unlike the matching
Avro and Protobuf forms. None of the three formats rejects a
TINYINT or SMALLINT value outside that type’s range, because
Avro and Protobuf store both as a 32-bit integer.
The generated name for an inline ROW or STRUCT, shown in the
preceding table as T_f for Avro and .T.FRow for Protobuf,
combines its enclosing type’s name and its own field name in both
formats. Renaming either one changes this generated name. Because
Avro and Protobuf compatibility checks match nested records or
messages by name, even a rename that’s normally safe for the field
itself, such as an Avro field with avro.aliases or a Protobuf field
that keeps its tag, can break compatibility for the nested type. To
keep the nested name stable, declare the nested type as a named
STRUCT instead of an inline ROW or STRUCT. JSON
Schema isn’t affected, because its title isn’t used for
compatibility matching.
For Protobuf, an ARRAY<T> element gets the ElementWrapper shown
in the mapping table when T is nullable, which it is by default, or
when T is a nested collection or UNION, whether or not it’s nullable. Write
ARRAY<STRING NOT NULL> to keep a plain repeated string on the
wire. Avro and JSON Schema need no wrapper either way: a nullable
element becomes a union or oneOf inside items, the same as any
other nullable field.
An ARRAY<T> field’s own nullability is different: proto3 forbids
optional repeated, so a nullable ARRAY, MAP, or
MULTISET field wraps the whole repeated value in a
<RepeatedWrapper> message instead, shown in the mapping table.
Presence on that wrapper message distinguishes a null array from an
empty one, because a message field can be unset independent of the
repeated value inside it. Write ARRAY<T> NOT NULL to keep a
plain repeated field with no wrapper. Avro and JSON Schema need no
wrapper either way: a null array is already a distinct value from an
empty one in both formats.
MULTISET<T> and MAP<K,V> don’t use ElementWrapper for
their element or value type, because their Entry message’s
key and value fields are ordinary struct fields, not array
elements. A nullable scalar V becomes an optional field on
Entry. A collection or UNION as V triggers the same
RepeatedWrapper or OneofWrapper a nullable field of that shape
gets anywhere else, because Entry’s fields go through the same
field-conversion rules as any other struct’s. For MULTISET<T>, the
element T is the Entry message’s key field and follows the
same rule, and value holds the count.
For the native map and additionalProperties forms, a MAP<K,V>
key’s own NOT NULL clause has no effect on which form it takes. These
forms require a plain CHAR, VARCHAR, or STRING key type,
regardless of NOT NULL, so MAP<STRING, V> and
MAP<STRING NOT NULL, V> produce the same native form. Keys in these
forms can’t be null, so a record with a null key fails at serialization.